From 22ef95e62153f641c422f25609f51e3a69a64f0f Mon Sep 17 00:00:00 2001
From: Grant Wallace <33526205+gdoubleyew@users.noreply.github.com>
Date: Thu, 9 Sep 2021 17:16:33 -0400
Subject: [PATCH] jsPsych subject feedback (#62)
* Initial jsPsych subject feedback implementation
* Uses jsPsych if-condition nodes and loop-condition nodes
* Documentation for subject feedback
* Add core jsPsych library files
---
Readme.md | 1 +
docs/subject-feedback.md | 19 +
projects/sample/sample.py | 19 +-
rtCommon/dataInterface.py | 4 +-
rtCommon/subjectInterface.py | 47 +-
rtCommon/webServer.py | 24 +-
tests/test_subjectInterface.py | 13 +-
web/html/jsPsychFeedback.html | 182 ++
web/jsPsych/README.md | 63 +
web/jsPsych/css/jspsych.css | 206 ++
web/jsPsych/jspsych.js | 3023 +++++++++++++++++++
web/jsPsych/license.txt | 21 +
web/src/jsPsychWebSocket.js | 91 +
web/src/jspsych-plugin-realtime-response.js | 131 +
14 files changed, 3825 insertions(+), 19 deletions(-)
create mode 100644 docs/subject-feedback.md
create mode 100644 web/html/jsPsychFeedback.html
create mode 100644 web/jsPsych/README.md
create mode 100644 web/jsPsych/css/jspsych.css
create mode 100755 web/jsPsych/jspsych.js
create mode 100644 web/jsPsych/license.txt
create mode 100644 web/src/jsPsychWebSocket.js
create mode 100644 web/src/jspsych-plugin-realtime-response.js
diff --git a/Readme.md b/Readme.md
index ef9e7688..951862cf 100644
--- a/Readme.md
+++ b/Readme.md
@@ -37,6 +37,7 @@ A subjectService is started on the presentation computer. The subjecService list
- [Running a Realtime Experiment](docs/how-to-run.md)
- [Run Project in a Docker Container](docs/run-in-docker.md)
- [Using BIDS Data Format in RT-Cloud](docs/using-bids-data.md)
+- [Providing Subject Feedback](docs/subject-feedback.md)
## Installation
diff --git a/docs/subject-feedback.md b/docs/subject-feedback.md
new file mode 100644
index 00000000..19297a22
--- /dev/null
+++ b/docs/subject-feedback.md
@@ -0,0 +1,19 @@
+# **Providing Feedback to Subjects**
+Ideally we would like to provide feedback to the subject in the MRI scanner via a web interface. This would allow the researcher to open a web browser and move the browser onto a monitor visible by the subject in the scanner. One convenient toolbox for doing this is jsPsych. We have integrated jsPsych into our project and provide a demo using the DecNef style colored circle feedback.
+
+## **Using jsPsych**
+The source code components of jsPsych live in the `web/` directory. File `web/jsPsychFeedback.html` is the main file that will be edited to adjust the type of feedback displayed. The creating a new `draw` method different types of feedback can be created.
+
+### **Running the Demo**
+1. The projectServer must be started with --remoteSubject options enabled to allow the feedback webpage to connect and receive results from the projectServer.
+ - conda activate rtcloud
+ - bash ./scripts/run-projectInterface.sh --test -p sample --subjectRemote
+
+2. Connect a web browser to the main page
+ - http://localhost:8888/
+ - Enter 'test' for both the usnername and password since we are running it in unsecure test mode.
+
+3. Connect a web browser to the jsPsych feedback page
+ - http://localhost:8888/jspsych
+
+4. Click the 'Run' button on the main page and view the subject feedback shown on the jsPsych page
\ No newline at end of file
diff --git a/projects/sample/sample.py b/projects/sample/sample.py
index 275a4995..3cc8a9d7 100644
--- a/projects/sample/sample.py
+++ b/projects/sample/sample.py
@@ -36,6 +36,7 @@
# import important modules
import os
import sys
+import time
import argparse
import warnings
import numpy as np
@@ -91,6 +92,8 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface):
OUTPUT:
None.
"""
+ subjInterface.setMessage("Preparing Run ...")
+ time.sleep(1)
# variables we'll use throughout
scanNum = cfg.scanNum[0]
@@ -296,7 +299,11 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface):
# the presentation computer.
if verbose:
print("| send result to the presentation computer for provide subject feedback")
- subjInterface.setResult(runNum, int(this_TR), float(avg_niftiData))
+ # convert to value between 0 and 1
+ minAvg = 305
+ maxAvg = 315
+ feedback = (avg_niftiData - minAvg) / (maxAvg - minAvg)
+ subjInterface.setResult(runNum, int(this_TR), float(feedback), 1000)
# Finally we will use use webInterface.plotDataPoint() to send the result
# to the web browser to be plotted in the --Data Plots-- tab.
@@ -310,12 +317,22 @@ def doRuns(cfg, dataInterface, subjInterface, webInterface):
# save the activations value info into a vector that can be saved later
all_avg_activations[this_TR] = avg_niftiData
+ time.sleep(1)
# create the full path filename of where we want to save the activation values vector.
# we're going to save things as .txt and .mat files
output_textFilename = '/tmp/cloud_directory/tmp/avg_activations.txt'
output_matFilename = os.path.join('/tmp/cloud_directory/tmp/avg_activations.mat')
+ time.sleep(1)
+ subjInterface.setMessage("End Run")
+ responses = subjInterface.getAllResponses()
+ keypresses = [response.get('key_pressed') for response in responses]
+ stimDurations = [response.get('stimulus_duration') for response in responses]
+ if verbose:
+ print(f'Keypresses: {keypresses}')
+ print(f'Durations: {stimDurations}')
+
# use 'putFile' from the dataInterface to save the .txt file
# INPUT:
# [1] filename (full path!)
diff --git a/rtCommon/dataInterface.py b/rtCommon/dataInterface.py
index 3eca57d5..40e84494 100644
--- a/rtCommon/dataInterface.py
+++ b/rtCommon/dataInterface.py
@@ -13,7 +13,6 @@
import re
import time
import glob
-import chardet
import threading
import logging
from pathlib import Path
@@ -461,4 +460,5 @@ def downloadFilesFromCloud(dataInterface, srcFilePattern :str, outputDir :str, d
# 'downloadFolderFromCloud',
# 'downloadFilesFromCloud',
# ]
-# self.addLocalAttributes(localOnlyFunctions)
\ No newline at end of file
+# self.addLocalAttributes(localOnlyFunctions)
+
diff --git a/rtCommon/subjectInterface.py b/rtCommon/subjectInterface.py
index cafc4892..de3807f7 100644
--- a/rtCommon/subjectInterface.py
+++ b/rtCommon/subjectInterface.py
@@ -13,6 +13,7 @@
import time
from queue import Queue, Empty
from rtCommon.remoteable import RemoteableExtensible
+from rtCommon.errors import ValidationError
class SubjectInterface(RemoteableExtensible):
@@ -41,35 +42,67 @@ def __init__(self, subjectRemote=False):
if subjectRemote is True:
return
self.msgQueue = Queue()
+ self.message = ""
- def setResult(self, runId :int, trId :int, value: float) -> None:
+ # NOTE: The below function implementations will only be used when there is no
+ # external subjectInterface connected to the projectServer. Thus these
+ # implementations are just for testing or as a place holder when no external
+ # subjectInterface is used.
+
+ def setResult(self, runId :int, trId :int, value: float, onsetTimeDelayMs: int=0) -> None:
"""
- Whe setResult is called by the experiment script it queues the result for
+ When setResult is called by the experiment script it queues the result for
the presentation script to later read and use to provide subject feedback.
Args:
runId: experiment specific identifier of the run
trId: volume number of the dicom within a run
value: the classification result from processing the dicom image for this TR
+ onsetTimeDelayMs: time in milliseconds to wait before presenting the feedback stimulus
"""
print(f'SubjectInterface: setResult: run {runId}, tr {trId}, value {value}')
+ if onsetTimeDelayMs < 0:
+ raise ValidationError(f'onsetTimeDelayMs must be >= 0, {onsetTimeDelayMs}')
+
feedbackMsg = {
'runId': runId,
'trId': trId,
'value': value,
+ 'onsetTimeDelayMs': onsetTimeDelayMs,
'timestamp': time.time()
}
self.msgQueue.put(feedbackMsg)
- def dequeueResult(self, block :bool=False, timeout :int=None) -> float:
+ def setMessage(self, message: str) -> None:
"""
- Return the next result value send by the experiment script. Used by the
- presentation script.
+ Updates the message displayed to the subject
"""
- return self.msgQueue.get(block=block, timeout=timeout)
+ print(f'SubjectInterface: setMessage: {message}')
+ self.message = message
def getResponse(self, runId :int, trId :int):
"""
Retrieve the subject response, used by the classification script.
+ See *note* above - these local versions of the function are just
+ for testing or as a place holder when no external subjectInterface
+ is used.
"""
print(f'SubjectInterface: getResponse: run {runId}, tr {trId}')
- pass
+ return {}
+
+ def getAllResponses(self):
+ """
+ Retrieve all subject responses since the last time this call was made
+ """
+ print(f'SubjectInterface: getAllResponses')
+ return [{}]
+
+ def dequeueResult(self, block :bool=False, timeout :int=None) -> float:
+ """
+ Return the next result value sent by the experiment script. Used by the
+ presentation script.
+ See *note* above - these local versions of the function are just
+ for testing or as a place holder when no external version is used.
+ """
+ return self.msgQueue.get(block=block, timeout=timeout)
+
+
diff --git a/rtCommon/webServer.py b/rtCommon/webServer.py
index 347349a5..e696cb8c 100644
--- a/rtCommon/webServer.py
+++ b/rtCommon/webServer.py
@@ -20,7 +20,7 @@
from rtCommon.certsUtils import getCertPath, getKeyPath
from rtCommon.structDict import StructDict, recurseCreateStructDict
from rtCommon.webHttpHandlers import HttpHandler, LoginHandler, LogoutHandler, certsDir
-from rtCommon.webSocketHandlers import sendWebSocketMessage, BaseWebSocketHandler
+from rtCommon.webSocketHandlers import BaseWebSocketHandler
from rtCommon.webDisplayInterface import WebDisplayInterface
from rtCommon.projectServerRPC import ProjectRPCService
from rtCommon.dataInterface import uploadFilesFromList
@@ -62,10 +62,13 @@ def start(params, cfg, testMode=False):
params.confDir = os.path.join(Web.webDir, 'conf/')
if params.port:
Web.httpPort = params.port
+ if not os.path.exists(certsDir):
+ os.makedirs(certsDir)
src_root = os.path.join(Web.webDir, 'src')
css_root = os.path.join(Web.webDir, 'css')
img_root = os.path.join(Web.webDir, 'img')
build_root = os.path.join(Web.webDir, 'build')
+ jsPsych_root = os.path.join(Web.webDir, 'jsPsych')
cookieSecret = getCookieSecret(certsDir)
settings = {
"cookie_secret": cookieSecret,
@@ -101,6 +104,7 @@ def start(params, cfg, testMode=False):
Web.app = tornado.web.Application([
(r'/', HttpHandler, dict(htmlDir=Web.htmlDir, page='index.html')),
(r'/feedback', HttpHandler, dict(htmlDir=Web.htmlDir, page='biofeedback.html')), # shows image
+ (r'/jspsych', HttpHandler, dict(htmlDir=Web.htmlDir, page='jsPsychFeedback.html')),
(r'/login', LoginHandler, dict(htmlDir=Web.htmlDir, page='login.html', testMode=Web.testMode)),
(r'/logout', LogoutHandler),
(r'/wsUser', BaseWebSocketHandler, dict(name='wsUser', callback=Web.browserRequestHandler._wsBrowserCallback)),
@@ -109,6 +113,10 @@ def start(params, cfg, testMode=False):
(r'/css/(.*)', tornado.web.StaticFileHandler, {'path': css_root}),
(r'/img/(.*)', tornado.web.StaticFileHandler, {'path': img_root}),
(r'/build/(.*)', tornado.web.StaticFileHandler, {'path': build_root}),
+ (r'/jspsych/(.*)', tornado.web.StaticFileHandler, {'path': jsPsych_root}),
+ # /wsSubject gets added in projectServer.py when remoteSubject is True
+ # /wsData gets added in projectServer.py when remoteData is True
+ # (r'/wsSubject', BaseWebSocketHandler, dict(name='wsSubject', callback=defaultWebsocketCallback)),
], **settings)
Web.httpServer = tornado.httpserver.HTTPServer(Web.app, ssl_options=ssl_ctx)
Web.httpServer.listen(Web.httpPort)
@@ -124,15 +132,15 @@ def stop():
"""Stop the web server."""
Web.ioLoopInst.add_callback(Web.ioLoopInst.stop)
Web.app = None
-
+
# Possibly use raise exception to stop a thread
# def raise_exception(self): i.e. for stop()
- # thread_id = self.get_id()
- # res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id,
- # ctypes.py_object(SystemExit))
- # if res > 1:
- # ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
- # print('Exception raise failure')
+ # thread_id = self.get_id()
+ # res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id,
+ # ctypes.py_object(SystemExit))
+ # if res > 1:
+ # ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
+ # print('Exception raise failure')
@staticmethod
def close():
diff --git a/tests/test_subjectInterface.py b/tests/test_subjectInterface.py
index b1e048f4..91b816ca 100644
--- a/tests/test_subjectInterface.py
+++ b/tests/test_subjectInterface.py
@@ -1,6 +1,7 @@
import pytest
from tests.backgroundTestServers import BackgroundTestServers
from rtCommon.clientInterface import ClientInterface
+from rtCommon.errors import ValidationError
class TestSubjectInterface:
@@ -37,10 +38,20 @@ def runSubjectFeedbackTest(isRemote):
runId = 3
for trId in range(1, 10):
value = 20 + trId
- subjInterface.setResult(runId, trId, value)
+ onsetTimeDelayMs = trId + 4
+ subjInterface.setResult(runId, trId, value, onsetTimeDelayMs)
for i in range(1, 10):
feedbackMsg = subjInterface.dequeueResult(block=False, timeout=1)
assert feedbackMsg['runId'] == runId
assert feedbackMsg['trId'] == i
assert feedbackMsg['value'] == 20 + i
+ assert feedbackMsg['onsetTimeDelayMs'] == i + 4
+
+ subjInterface.setResult(1, 2, 3, 3.1)
+ feedbackMsg = subjInterface.dequeueResult(block=False, timeout=1)
+ assert feedbackMsg['onsetTimeDelayMs'] == 3.1
+
+ with pytest.raises((ValidationError, Exception)):
+ # Try setting a negative onsetTimeDelay
+ subjInterface.setResult(1, 2, 3, -1)
\ No newline at end of file
diff --git a/web/html/jsPsychFeedback.html b/web/html/jsPsychFeedback.html
new file mode 100644
index 00000000..e13583d6
--- /dev/null
+++ b/web/html/jsPsychFeedback.html
@@ -0,0 +1,182 @@
+
+
+
+
+
+ jsPsych Subject Feedback
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/jsPsych/README.md b/web/jsPsych/README.md
new file mode 100644
index 00000000..7989832c
--- /dev/null
+++ b/web/jsPsych/README.md
@@ -0,0 +1,63 @@
+![logo](http://www.jspsych.org/img/jspsych-logo.jpg)
+
+jsPsych is a JavaScript library for creating behavioral experiments that run in a web browser. It provides a framework for defining experiments using a set of flexible plugins that create different kinds of events, and collect different kinds of data. By assembling these plugins together, it is possible to create a wide range of online experiments.
+
+jsPsych experiments are created using the languages of the Web: HTML, CSS, and JavaScript. JavaScript is the programming language used by web browsers. It provides the most control and flexibility for creating web-based experiments, and allows for easy integration with other JavaScript libraries and server-side tools. Don't have JavaScript experience? Don't worry! jsPsych was designed to make creating online experiments as easy as possible for people without web development experience.
+
+## What can I do with jsPsych?
+
+jsPsych comes with a number of plugins that you can use create tasks and collect data. Some plugins do general things, like present a stimulus (text, image, audio, video) and record a key press or button response along with a response time. Other plugins do more specific things, like show a set of instructions pages, run a drag-and-drop image sorting task, present a Random-Dot Kinematogram, or calibrate the WebGazer eye-tracking extension. See the documentation website for a [list of all plugins](https://www.jspsych.org/plugins/list-of-plugins/), and to see what each plugin can do.
+
+Often people can create their experiment by combining these plugins together. But if that's not possible for your experiment, you can also modify a plugin file or [create your own plugin](https://www.jspsych.org/overview/plugins/#creating-a-new-plugin). This gives you the flexibility to do exactly what you want, while still taking advantage of jsPsych's general experiment-building framework.
+
+Getting started
+---------------
+
+New to jsPsych? A good place to start is the basic [Hello World tutorial](https://www.jspsych.org/tutorials/hello-world/) on the jsPsych website. The [Reaction Time Task tutorial](https://www.jspsych.org/tutorials/rt-task/) is a great next step, since it covers many core topics and features.
+
+There are also a number of [video tutorials](https://www.jspsych.org/tutorials/video-tutorials), including [Session 1 of the Moving Online Workshop](https://www.youtube.com/watch?v=BuhfsIFRFe8), which provides an overview of jsPsych suitable for brand new users.
+
+Examples
+----------
+
+Several example experiments and plugin demonstrations are available in the `/examples` folder. After you've downloaded the [latest release](https://github.com/jspsych/jsPsych/releases), double-click on an example HTML file to run it in your web browser, and open it with a programming-friendly text editor to see how it works.
+
+Documentation
+-------------
+
+Documentation is available at [jspsych.org](https://www.jspsych.org/).
+
+Need help?
+----------
+
+For questions about using the library, please use the GitHub [Discussions forum](https://github.com/jspsych/jsPsych/discussions).
+
+Contributing
+------------
+
+Contributions to the code are welcome. Please use the [Issue tracker system](https://github.com/jspsych/jsPsych/issues) to report bugs or discuss suggestions for new features and improvements. If you would like to contribute code, [submit a Pull request](https://help.github.com/articles/using-pull-requests). See the [Contributing to jsPsych](https://www.jspsych.org/about/contributing/) documentation page for more information.
+
+Citation
+--------
+
+If you use this library in academic work, please cite the [paper that describes jsPsych](http://link.springer.com/article/10.3758%2Fs13428-014-0458-y):
+
+de Leeuw, J.R. (2015). jsPsych: A JavaScript library for creating behavioral experiments in a Web browser. *Behavior Research Methods*, _47_(1), 1-12. doi:10.3758/s13428-014-0458-y
+
+Response times
+--------------
+
+Wondering if jsPsych can be used for research that depends on accurate response time measurement? For most purposes, the answer is yes. Response time measurements in jsPsych (and JavaScript in general) are comparable to those taken in standard lab software like Psychophysics Toolbox and E-Prime. Response times measured in JavaScript tend to be a little bit longer (10-40ms), but have similar variance. See the following references for extensive work on this topic.
+
+* [de Leeuw, J. R., & Motz, B. A. (2016). Psychophysics in a Web browser? Comparing response times collected with JavaScript and Psychophysics Toolbox in a visual search task. *Behavior Research Methods*, *48*(1), 1-12.](http://link.springer.com/article/10.3758%2Fs13428-015-0567-2)
+* [Hilbig, B. E. (2016). Reaction time effects in lab- versus web-based research: Experimental evidence. *Behavior Research Methods*, *48*(4), 1718-1724.](http://dx.doi.org/10.3758/s13428-015-0678-9)
+* [Pinet, S., Zielinski, C., Mathôt, S. et al. (2017). Measuring sequences of keystrokes with jsPsych: Reliability of response times and interkeystroke intervals. *Behavior Research Methods*, *49*(3), 1163-1176.](http://link.springer.com/article/10.3758/s13428-016-0776-3)
+* [Reimers, S., & Stewart, N. (2015). Presentation and response time accuracy in Adobe Flash and HTML5/JavaScript Web experiments. *Behavior Research Methods*, *47*(2), 309-327.](http://link.springer.com/article/10.3758%2Fs13428-014-0471-1)
+
+
+Credits
+-------
+
+jsPsych was created by Josh de Leeuw ([@jodeleeuw](https://github.com/jodeleeuw)).
+
+We're grateful for the many [contributors](https://github.com/jspsych/jsPsych/blob/master/contributors.md) to the library, and for the generous support from a [Mozilla Open Source Support (MOSS)](https://www.mozilla.org/en-US/moss/) award. Thank you!
\ No newline at end of file
diff --git a/web/jsPsych/css/jspsych.css b/web/jsPsych/css/jspsych.css
new file mode 100644
index 00000000..9a07da4d
--- /dev/null
+++ b/web/jsPsych/css/jspsych.css
@@ -0,0 +1,206 @@
+/*
+ * CSS for jsPsych experiments.
+ *
+ * This stylesheet provides minimal styling to make jsPsych
+ * experiments look polished without any additional styles.
+ */
+
+ @import url(https://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700);
+
+/* Container holding jsPsych content */
+
+ .jspsych-display-element {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ }
+
+ .jspsych-display-element:focus {
+ outline: none;
+ }
+
+ .jspsych-content-wrapper {
+ display: flex;
+ margin: auto;
+ flex: 1 1 100%;
+ width: 100%;
+ }
+
+ .jspsych-content {
+ max-width: 95%; /* this is mainly an IE 10-11 fix */
+ text-align: center;
+ margin: auto; /* this is for overflowing content */
+ }
+
+ .jspsych-top {
+ align-items: flex-start;
+ }
+
+ .jspsych-middle {
+ align-items: center;
+ }
+
+/* fonts and type */
+
+.jspsych-display-element {
+ font-family: 'Open Sans', 'Arial', sans-serif;
+ font-size: 18px;
+ line-height: 1.6em;
+}
+
+/* Form elements like input fields and buttons */
+
+.jspsych-display-element input[type="text"] {
+ font-family: 'Open Sans', 'Arial', sans-serif;
+ font-size: 14px;
+}
+
+/* borrowing Bootstrap style for btn elements, but combining styles a bit */
+.jspsych-btn {
+ display: inline-block;
+ padding: 6px 12px;
+ margin: 0px;
+ font-size: 14px;
+ font-weight: 400;
+ font-family: 'Open Sans', 'Arial', sans-serif;
+ cursor: pointer;
+ line-height: 1.4;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+ background-image: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: #333;
+ background-color: #fff;
+ border-color: #ccc;
+}
+
+/* only apply the hover style on devices with a mouse/pointer that can hover - issue #977 */
+@media (hover: hover) {
+ .jspsych-btn:hover {
+ background-color: #ddd;
+ border-color: #aaa;
+ }
+}
+
+.jspsych-btn:active {
+ background-color: #ddd;
+ border-color:#000000;
+}
+
+.jspsych-btn:disabled {
+ background-color: #eee;
+ color: #aaa;
+ border-color: #ccc;
+ cursor: not-allowed;
+}
+
+/* custom style for input[type="range] (slider) to improve alignment between positions and labels */
+
+.jspsych-slider {
+ appearance: none;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ width: 100%;
+ background: transparent;
+}
+.jspsych-slider:focus {
+ outline: none;
+}
+/* track */
+.jspsych-slider::-webkit-slider-runnable-track {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 100%;
+ height: 8px;
+ cursor: pointer;
+ background: #eee;
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ border-radius: 2px;
+ border: 1px solid #aaa;
+}
+.jspsych-slider::-moz-range-track {
+ appearance: none;
+ width: 100%;
+ height: 8px;
+ cursor: pointer;
+ background: #eee;
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ border-radius: 2px;
+ border: 1px solid #aaa;
+}
+.jspsych-slider::-ms-track {
+ appearance: none;
+ width: 99%;
+ height: 14px;
+ cursor: pointer;
+ background: #eee;
+ box-shadow: 0px 0px 0px #000000, 0px 0px 0px #0d0d0d;
+ border-radius: 2px;
+ border: 1px solid #aaa;
+}
+/* thumb */
+.jspsych-slider::-webkit-slider-thumb {
+ border: 1px solid #666;
+ height: 24px;
+ width: 15px;
+ border-radius: 5px;
+ background: #ffffff;
+ cursor: pointer;
+ -webkit-appearance: none;
+ margin-top: -9px;
+}
+.jspsych-slider::-moz-range-thumb {
+ border: 1px solid #666;
+ height: 24px;
+ width: 15px;
+ border-radius: 5px;
+ background: #ffffff;
+ cursor: pointer;
+}
+.jspsych-slider::-ms-thumb {
+ border: 1px solid #666;
+ height: 20px;
+ width: 15px;
+ border-radius: 5px;
+ background: #ffffff;
+ cursor: pointer;
+ margin-top: -2px;
+}
+
+/* jsPsych progress bar */
+
+#jspsych-progressbar-container {
+ color: #555;
+ border-bottom: 1px solid #dedede;
+ background-color: #f9f9f9;
+ margin-bottom: 1em;
+ text-align: center;
+ padding: 8px 0px;
+ width: 100%;
+ line-height: 1em;
+}
+#jspsych-progressbar-container span {
+ font-size: 14px;
+ padding-right: 14px;
+}
+#jspsych-progressbar-outer {
+ background-color: #eee;
+ width: 50%;
+ margin: auto;
+ height: 14px;
+ display: inline-block;
+ vertical-align: middle;
+ box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
+}
+#jspsych-progressbar-inner {
+ background-color: #aaa;
+ width: 0%;
+ height: 100%;
+}
+
+/* Control appearance of jsPsych.data.displayData() */
+#jspsych-data-display {
+ text-align: left;
+}
diff --git a/web/jsPsych/jspsych.js b/web/jsPsych/jspsych.js
new file mode 100755
index 00000000..e051b9df
--- /dev/null
+++ b/web/jsPsych/jspsych.js
@@ -0,0 +1,3023 @@
+window.jsPsych = (function() {
+
+ var core = {};
+
+ core.version = function() { return "6.3.0" };
+
+ //
+ // private variables
+ //
+
+ // options
+ var opts = {};
+ // experiment timeline
+ var timeline;
+ // flow control
+ var global_trial_index = 0;
+ var current_trial = {};
+ var current_trial_finished = false;
+ // target DOM element
+ var DOM_container;
+ var DOM_target;
+ // time that the experiment began
+ var exp_start_time;
+ // is the experiment paused?
+ var paused = false;
+ var waiting = false;
+ // done loading?
+ var loaded = false;
+ var loadfail = false;
+ // is the page retrieved directly via file:// protocol (true) or hosted on a server (false)?
+ var file_protocol = false;
+
+ // storing a single webaudio context to prevent problems with multiple inits
+ // of jsPsych
+ core.webaudio_context = null;
+ // temporary patch for Safari
+ if (typeof window !== 'undefined' && window.hasOwnProperty('webkitAudioContext') && !window.hasOwnProperty('AudioContext')) {
+ window.AudioContext = webkitAudioContext;
+ }
+ // end patch
+ core.webaudio_context = (typeof window !== 'undefined' && typeof window.AudioContext !== 'undefined') ? new AudioContext() : null;
+
+ // enumerated variables for special parameter types
+ core.ALL_KEYS = 'allkeys';
+ core.NO_KEYS = 'none';
+
+ //
+ // public methods
+ //
+
+ core.init = function(options) {
+ function init() {
+ if(typeof options.timeline === 'undefined'){
+ console.error('No timeline declared in jsPsych.init. Cannot start experiment.')
+ }
+
+ if(options.timeline.length == 0){
+ console.error('No trials have been added to the timeline (the timeline is an empty array). Cannot start experiment.')
+ }
+
+ // reset variables
+ timeline = null;
+ global_trial_index = 0;
+ current_trial = {};
+ current_trial_finished = false;
+ paused = false;
+ waiting = false;
+ loaded = false;
+ loadfail = false;
+ file_protocol = false;
+ jsPsych.data.reset();
+
+ var defaults = {
+ 'display_element': undefined,
+ 'on_finish': function(data) {
+ return undefined;
+ },
+ 'on_trial_start': function(trial) {
+ return undefined;
+ },
+ 'on_trial_finish': function() {
+ return undefined;
+ },
+ 'on_data_update': function(data) {
+ return undefined;
+ },
+ 'on_interaction_data_update': function(data){
+ return undefined;
+ },
+ 'on_close': function(){
+ return undefined;
+ },
+ 'use_webaudio': true,
+ 'exclusions': {},
+ 'show_progress_bar': false,
+ 'message_progress_bar': 'Completion Progress',
+ 'auto_update_progress_bar': true,
+ 'default_iti': 0,
+ 'minimum_valid_rt': 0,
+ 'experiment_width': null,
+ 'override_safe_mode': false,
+ 'case_sensitive_responses': false,
+ 'extensions': []
+ };
+
+ // detect whether page is running in browser as a local file, and if so, disable web audio and video preloading to prevent CORS issues
+ if (window.location.protocol == 'file:' && (options.override_safe_mode === false || typeof options.override_safe_mode == 'undefined')) {
+ options.use_webaudio = false;
+ file_protocol = true;
+ console.warn("jsPsych detected that it is running via the file:// protocol and not on a web server. "+
+ "To prevent issues with cross-origin requests, Web Audio and video preloading have been disabled. "+
+ "If you would like to override this setting, you can set 'override_safe_mode' to 'true' in jsPsych.init. "+
+ "For more information, see: https://www.jspsych.org/overview/running-experiments");
+ }
+
+ // override default options if user specifies an option
+ opts = Object.assign({}, defaults, options);
+
+ // set DOM element where jsPsych will render content
+ // if undefined, then jsPsych will use the tag and the entire page
+ if(typeof opts.display_element == 'undefined'){
+ // check if there is a body element on the page
+ var body = document.querySelector('body');
+ if (body === null) {
+ document.documentElement.appendChild(document.createElement('body'));
+ }
+ // using the full page, so we need the HTML element to
+ // have 100% height, and body to be full width and height with
+ // no margin
+ document.querySelector('html').style.height = '100%';
+ document.querySelector('body').style.margin = '0px';
+ document.querySelector('body').style.height = '100%';
+ document.querySelector('body').style.width = '100%';
+ opts.display_element = document.querySelector('body');
+ } else {
+ // make sure that the display element exists on the page
+ var display;
+ if (opts.display_element instanceof Element) {
+ var display = opts.display_element;
+ } else {
+ var display = document.querySelector('#' + opts.display_element);
+ }
+ if(display === null) {
+ console.error('The display_element specified in jsPsych.init() does not exist in the DOM.');
+ } else {
+ opts.display_element = display;
+ }
+ }
+ opts.display_element.innerHTML = '
';
+ DOM_container = opts.display_element;
+ DOM_target = document.querySelector('#jspsych-content');
+
+
+ // add tabIndex attribute to scope event listeners
+ opts.display_element.tabIndex = 0;
+
+ // add CSS class to DOM_target
+ if(opts.display_element.className.indexOf('jspsych-display-element') == -1){
+ opts.display_element.className += ' jspsych-display-element';
+ }
+ DOM_target.className += 'jspsych-content';
+
+ // set experiment_width if not null
+ if(opts.experiment_width !== null){
+ DOM_target.style.width = opts.experiment_width + "px";
+ }
+
+ // create experiment timeline
+ timeline = new TimelineNode({
+ timeline: opts.timeline
+ });
+
+ // initialize audio context based on options and browser capabilities
+ jsPsych.pluginAPI.initAudio();
+
+ // below code resets event listeners that may have lingered from
+ // a previous incomplete experiment loaded in same DOM.
+ jsPsych.pluginAPI.reset(opts.display_element);
+ // create keyboard event listeners
+ jsPsych.pluginAPI.createKeyboardEventListeners(opts.display_element);
+ // create listeners for user browser interaction
+ jsPsych.data.createInteractionListeners();
+
+ // add event for closing window
+ window.addEventListener('beforeunload', opts.on_close);
+
+ // check exclusions before continuing
+ checkExclusions(opts.exclusions,
+ function(){
+ // success! user can continue...
+ // start experiment
+ loadExtensions();
+ },
+ function(){
+ // fail. incompatible user.
+ }
+ );
+
+ function loadExtensions() {
+ // run the .initialize method of any extensions that are in use
+ // these should return a Promise to indicate when loading is complete
+ if (opts.extensions.length == 0) {
+ startExperiment();
+ } else {
+ var loaded_extensions = 0;
+ for (var i = 0; i < opts.extensions.length; i++) {
+ var ext_params = opts.extensions[i].params;
+ if (!ext_params) {
+ ext_params = {}
+ }
+ jsPsych.extensions[opts.extensions[i].type].initialize(ext_params)
+ .then(() => {
+ loaded_extensions++;
+ if (loaded_extensions == opts.extensions.length) {
+ startExperiment();
+ }
+ })
+ .catch((error_message) => {
+ console.error(error_message);
+ })
+ }
+ }
+ }
+
+ };
+
+ // execute init() when the document is ready
+ if (document.readyState === "complete") {
+ init();
+ } else {
+ window.addEventListener("load", init);
+ }
+ }
+
+ core.progress = function() {
+
+ var percent_complete = typeof timeline == 'undefined' ? 0 : timeline.percentComplete();
+
+ var obj = {
+ "total_trials": typeof timeline == 'undefined' ? undefined : timeline.length(),
+ "current_trial_global": global_trial_index,
+ "percent_complete": percent_complete
+ };
+
+ return obj;
+ };
+
+ core.startTime = function() {
+ return exp_start_time;
+ };
+
+ core.totalTime = function() {
+ if(typeof exp_start_time == 'undefined'){ return 0; }
+ return (new Date()).getTime() - exp_start_time.getTime();
+ };
+
+ core.getDisplayElement = function() {
+ return DOM_target;
+ };
+
+ core.getDisplayContainerElement = function(){
+ return DOM_container;
+ }
+
+ core.finishTrial = function(data) {
+
+ if(current_trial_finished){ return; }
+ current_trial_finished = true;
+
+ // remove any CSS classes that were added to the DOM via css_classes parameter
+ if(typeof current_trial.css_classes !== 'undefined' && Array.isArray(current_trial.css_classes)){
+ DOM_target.classList.remove(...current_trial.css_classes);
+ }
+
+ // write the data from the trial
+ data = typeof data == 'undefined' ? {} : data;
+ jsPsych.data.write(data);
+
+ // get back the data with all of the defaults in
+ var trial_data = jsPsych.data.get().filter({trial_index: global_trial_index});
+
+ // for trial-level callbacks, we just want to pass in a reference to the values
+ // of the DataCollection, for easy access and editing.
+ var trial_data_values = trial_data.values()[0];
+
+ if(typeof current_trial.save_trial_parameters == 'object'){
+ var keys = Object.keys(current_trial.save_trial_parameters);
+ for(var i=0; i 0) {
+ setTimeout(nextTrial, opts.default_iti);
+ } else {
+ nextTrial();
+ }
+ } else {
+ if (current_trial.post_trial_gap > 0) {
+ setTimeout(nextTrial, current_trial.post_trial_gap);
+ } else {
+ nextTrial();
+ }
+ }
+ }
+
+ core.endExperiment = function(end_message) {
+ timeline.end_message = end_message;
+ timeline.end();
+ jsPsych.pluginAPI.cancelAllKeyboardResponses();
+ jsPsych.pluginAPI.clearAllTimeouts();
+ core.finishTrial();
+ }
+
+ core.endCurrentTimeline = function() {
+ timeline.endActiveNode();
+ }
+
+ core.currentTrial = function() {
+ return current_trial;
+ };
+
+ core.initSettings = function() {
+ return opts;
+ };
+
+ core.currentTimelineNodeID = function() {
+ return timeline.activeID();
+ };
+
+ core.timelineVariable = function(varname, immediate){
+ if(typeof immediate == 'undefined'){ immediate = false; }
+ if(jsPsych.internal.call_immediate || immediate === true){
+ return timeline.timelineVariable(varname);
+ } else {
+ return function() { return timeline.timelineVariable(varname); }
+ }
+ }
+
+ core.allTimelineVariables = function(){
+ return timeline.allTimelineVariables();
+ }
+
+ core.addNodeToEndOfTimeline = function(new_timeline, preload_callback){
+ timeline.insert(new_timeline);
+ }
+
+ core.pauseExperiment = function(){
+ paused = true;
+ }
+
+ core.resumeExperiment = function(){
+ paused = false;
+ if(waiting){
+ waiting = false;
+ nextTrial();
+ }
+ }
+
+ core.loadFail = function(message){
+ message = message || '
The experiment failed to load.
';
+ loadfail = true;
+ DOM_target.innerHTML = message;
+ }
+
+ core.getSafeModeStatus = function() {
+ return file_protocol;
+ }
+
+ function TimelineNode(parameters, parent, relativeID) {
+
+ // a unique ID for this node, relative to the parent
+ var relative_id;
+
+ // store the parent for this node
+ var parent_node;
+
+ // parameters for the trial if the node contains a trial
+ var trial_parameters;
+
+ // parameters for nodes that contain timelines
+ var timeline_parameters;
+
+ // stores trial information on a node that contains a timeline
+ // used for adding new trials
+ var node_trial_data;
+
+ // track progress through the node
+ var progress = {
+ current_location: -1, // where on the timeline (which timelinenode)
+ current_variable_set: 0, // which set of variables to use from timeline_variables
+ current_repetition: 0, // how many times through the variable set on this run of the node
+ current_iteration: 0, // how many times this node has been revisited
+ done: false
+ }
+
+ // reference to self
+ var self = this;
+
+ // recursively get the next trial to run.
+ // if this node is a leaf (trial), then return the trial.
+ // otherwise, recursively find the next trial in the child timeline.
+ this.trial = function() {
+ if (typeof timeline_parameters == 'undefined') {
+ // returns a clone of the trial_parameters to
+ // protect functions.
+ return jsPsych.utils.deepCopy(trial_parameters);
+ } else {
+ if (progress.current_location >= timeline_parameters.timeline.length) {
+ return null;
+ } else {
+ return timeline_parameters.timeline[progress.current_location].trial();
+ }
+ }
+ }
+
+ this.markCurrentTrialComplete = function() {
+ if(typeof timeline_parameters == 'undefined'){
+ progress.done = true;
+ } else {
+ timeline_parameters.timeline[progress.current_location].markCurrentTrialComplete();
+ }
+ }
+
+ this.nextRepetiton = function() {
+ this.setTimelineVariablesOrder();
+ progress.current_location = -1;
+ progress.current_variable_set = 0;
+ progress.current_repetition++;
+ for (var i = 0; i < timeline_parameters.timeline.length; i++) {
+ timeline_parameters.timeline[i].reset();
+ }
+ }
+
+ // set the order for going through the timeline variables array
+ this.setTimelineVariablesOrder = function() {
+
+ // check to make sure this node has variables
+ if(typeof timeline_parameters === 'undefined' || typeof timeline_parameters.timeline_variables === 'undefined'){
+ return;
+ }
+
+ var order = [];
+ for(var i=0; i 1, and only when on the first variable set
+ if (typeof timeline_parameters.conditional_function !== 'undefined' && progress.current_repetition == 0 && progress.current_variable_set == 0) {
+ jsPsych.internal.call_immediate = true;
+ var conditional_result = timeline_parameters.conditional_function();
+ jsPsych.internal.call_immediate = false;
+ // if the conditional_function() returns false, then the timeline
+ // doesn't run and is marked as complete.
+ if (conditional_result == false) {
+ progress.done = true;
+ return true;
+ }
+ }
+
+ // if we reach this point then the node has its own timeline and will start
+ // so we need to check if there is an on_timeline_start function if we are on the first variable set
+ if (typeof timeline_parameters.on_timeline_start !== 'undefined' && progress.current_variable_set == 0) {
+ timeline_parameters.on_timeline_start();
+ }
+
+
+ }
+ // if we reach this point, then either the node doesn't have a timeline of the
+ // conditional function returned true and it can start
+ progress.current_location = 0;
+ // call advance again on this node now that it is pointing to a new location
+ return this.advance();
+ }
+
+ // if this node has a timeline, propogate down to the current trial.
+ if (typeof timeline_parameters !== 'undefined') {
+
+ var have_node_to_run = false;
+ // keep incrementing the location in the timeline until one of the nodes reached is incomplete
+ while (progress.current_location < timeline_parameters.timeline.length && have_node_to_run == false) {
+
+ // check to see if the node currently pointed at is done
+ var target_complete = timeline_parameters.timeline[progress.current_location].advance();
+ if (!target_complete) {
+ have_node_to_run = true;
+ return false;
+ } else {
+ progress.current_location++;
+ }
+
+ }
+
+ // if we've reached the end of the timeline (which, if the code is here, we have)
+
+ // there are a few steps to see what to do next...
+
+ // first, check the timeline_variables to see if we need to loop through again
+ // with a new set of variables
+ if (progress.current_variable_set < progress.order.length - 1) {
+ // reset the progress of the node to be with the new set
+ this.nextSet();
+ // then try to advance this node again.
+ return this.advance();
+ }
+
+ // if we're all done with the timeline_variables, then check to see if there are more repetitions
+ else if (progress.current_repetition < timeline_parameters.repetitions - 1) {
+ this.nextRepetiton();
+ // check to see if there is an on_timeline_finish function
+ if (typeof timeline_parameters.on_timeline_finish !== 'undefined') {
+ timeline_parameters.on_timeline_finish();
+ }
+ return this.advance();
+ }
+
+
+ // if we're all done with the repetitions...
+ else {
+ // check to see if there is an on_timeline_finish function
+ if (typeof timeline_parameters.on_timeline_finish !== 'undefined') {
+ timeline_parameters.on_timeline_finish();
+ }
+
+ // if we're all done with the repetitions, check if there is a loop function.
+ if (typeof timeline_parameters.loop_function !== 'undefined') {
+ jsPsych.internal.call_immediate = true;
+ if (timeline_parameters.loop_function(this.generatedData())) {
+ this.reset();
+ jsPsych.internal.call_immediate = false;
+ return parent_node.advance();
+ } else {
+ progress.done = true;
+ jsPsych.internal.call_immediate = false;
+ return true;
+ }
+ }
+
+
+ }
+
+ // no more loops on this timeline, we're done!
+ progress.done = true;
+ return true;
+ }
+ }
+
+ // check the status of the done flag
+ this.isComplete = function() {
+ return progress.done;
+ }
+
+ // getter method for timeline variables
+ this.getTimelineVariableValue = function(variable_name){
+ if(typeof timeline_parameters == 'undefined'){
+ return undefined;
+ }
+ var v = timeline_parameters.timeline_variables[progress.order[progress.current_variable_set]][variable_name];
+ return v;
+ }
+
+ // recursive upward search for timeline variables
+ this.findTimelineVariable = function(variable_name){
+ var v = this.getTimelineVariableValue(variable_name);
+ if(typeof v == 'undefined'){
+ if(typeof parent_node !== 'undefined'){
+ return parent_node.findTimelineVariable(variable_name);
+ } else {
+ return undefined;
+ }
+ } else {
+ return v;
+ }
+ }
+
+ // recursive downward search for active trial to extract timeline variable
+ this.timelineVariable = function(variable_name){
+ if(typeof timeline_parameters == 'undefined'){
+ return this.findTimelineVariable(variable_name);
+ } else {
+ // if progress.current_location is -1, then the timeline variable is being evaluated
+ // in a function that runs prior to the trial starting, so we should treat that trial
+ // as being the active trial for purposes of finding the value of the timeline variable
+ var loc = Math.max(0, progress.current_location);
+ // if loc is greater than the number of elements on this timeline, then the timeline
+ // variable is being evaluated in a function that runs after the trial on the timeline
+ // are complete but before advancing to the next (like a loop_function).
+ // treat the last active trial as the active trial for this purpose.
+ if(loc == timeline_parameters.timeline.length){
+ loc = loc - 1;
+ }
+ // now find the variable
+ return timeline_parameters.timeline[loc].timelineVariable(variable_name);
+ }
+ }
+
+ // recursively get all the timeline variables for this trial
+ this.allTimelineVariables = function(){
+ var all_tvs = this.allTimelineVariablesNames();
+ var all_tvs_vals = {};
+ for(var i=0; i'+
+ '
The minimum width is '+mw+'px. Your current width is '+w+'px.
'+
+ '
The minimum height is '+mh+'px. Your current height is '+h+'px.
';
+ core.getDisplayElement().innerHTML = msg;
+ } else {
+ clearInterval(interval);
+ core.getDisplayElement().innerHTML = '';
+ checkExclusions(exclusions, success, fail);
+ }
+ }, 100);
+ return; // prevents checking other exclusions while this is being fixed
+ }
+ }
+
+ // WEB AUDIO API
+ if(typeof exclusions.audio !== 'undefined' && exclusions.audio) {
+ if(window.hasOwnProperty('AudioContext') || window.hasOwnProperty('webkitAudioContext')){
+ // clear
+ } else {
+ clear = false;
+ var msg = '
Your browser does not support the WebAudio API, which means that you will not '+
+ 'be able to complete the experiment.
Browsers that support the WebAudio API include '+
+ 'Chrome, Firefox, Safari, and Edge.
');
+ }
+
+ function updateProgressBar() {
+ var progress = jsPsych.progress().percent_complete;
+ core.setProgressBar(progress / 100);
+ }
+
+ var progress_bar_amount = 0;
+
+ core.setProgressBar = function(proportion_complete){
+ proportion_complete = Math.max(Math.min(1,proportion_complete),0);
+ document.querySelector('#jspsych-progressbar-inner').style.width = (proportion_complete*100) + "%";
+ progress_bar_amount = proportion_complete;
+ }
+
+ core.getProgressBarCompleted = function(){
+ return progress_bar_amount;
+ }
+
+ //Leave a trace in the DOM that jspsych was loaded
+ document.documentElement.setAttribute('jspsych', 'present');
+
+ return core;
+})();
+
+jsPsych.internal = (function() {
+ var module = {};
+
+ // this flag is used to determine whether we are in a scope where
+ // jsPsych.timelineVariable() should be executed immediately or
+ // whether it should return a function to access the variable later.
+ module.call_immediate = false;
+
+ return module;
+})();
+
+jsPsych.plugins = (function() {
+
+ var module = {};
+
+ // enumerate possible parameter types for plugins
+ module.parameterType = {
+ BOOL: 0,
+ STRING: 1,
+ INT: 2,
+ FLOAT: 3,
+ FUNCTION: 4,
+ KEY: 5,
+ SELECT: 6,
+ HTML_STRING: 7,
+ IMAGE: 8,
+ AUDIO: 9,
+ VIDEO: 10,
+ OBJECT: 11,
+ COMPLEX: 12,
+ TIMELINE: 13
+ }
+
+ module.universalPluginParameters = {
+ data: {
+ type: module.parameterType.OBJECT,
+ pretty_name: 'Data',
+ default: {},
+ description: 'Data to add to this trial (key-value pairs)'
+ },
+ on_start: {
+ type: module.parameterType.FUNCTION,
+ pretty_name: 'On start',
+ default: function() { return; },
+ description: 'Function to execute when trial begins'
+ },
+ on_finish: {
+ type: module.parameterType.FUNCTION,
+ pretty_name: 'On finish',
+ default: function() { return; },
+ description: 'Function to execute when trial is finished'
+ },
+ on_load: {
+ type: module.parameterType.FUNCTION,
+ pretty_name: 'On load',
+ default: function() { return; },
+ description: 'Function to execute after the trial has loaded'
+ },
+ post_trial_gap: {
+ type: module.parameterType.INT,
+ pretty_name: 'Post trial gap',
+ default: null,
+ description: 'Length of gap between the end of this trial and the start of the next trial'
+ },
+ css_classes: {
+ type: module.parameterType.STRING,
+ pretty_name: 'Custom CSS classes',
+ default: null,
+ description: 'A list of CSS classes to add to the jsPsych display element for the duration of this trial'
+ }
+ }
+
+ return module;
+})();
+
+jsPsych.extensions = (function(){
+ return {};
+})();
+
+jsPsych.data = (function() {
+
+ var module = {};
+
+ // data storage object
+ var allData = DataCollection();
+
+ // browser interaction event data
+ var interactionData = DataCollection();
+
+ // data properties for all trials
+ var dataProperties = {};
+
+ // cache the query_string
+ var query_string;
+
+ // DataCollection
+ function DataCollection(data){
+
+ var data_collection = {};
+
+ var trials = typeof data === 'undefined' ? [] : data;
+
+ data_collection.push = function(new_data){
+ trials.push(new_data);
+ return data_collection;
+ }
+
+ data_collection.join = function(other_data_collection){
+ trials = trials.concat(other_data_collection.values());
+ return data_collection;
+ }
+
+ data_collection.top = function(){
+ if(trials.length <= 1){
+ return data_collection;
+ } else {
+ return DataCollection([trials[trials.length-1]]);
+ }
+ }
+
+ /**
+ * Queries the first n elements in a collection of trials.
+ *
+ * @param {number} n A positive integer of elements to return. A value of
+ * n that is less than 1 will throw an error.
+ *
+ * @return {Array} First n objects of a collection of trials. If fewer than
+ * n trials are available, the trials.length elements will
+ * be returned.
+ *
+ */
+ data_collection.first = function(n){
+ if (typeof n == 'undefined') { n = 1 }
+ if (n < 1) {
+ throw `You must query with a positive nonzero integer. Please use a
+ different value for n.`;
+ }
+ if (trials.length == 0) return DataCollection([]);
+ if (n > trials.length) n = trials.length;
+ return DataCollection(trials.slice(0, n));
+ }
+
+ /**
+ * Queries the last n elements in a collection of trials.
+ *
+ * @param {number} n A positive integer of elements to return. A value of
+ * n that is less than 1 will throw an error.
+ *
+ * @return {Array} Last n objects of a collection of trials. If fewer than
+ * n trials are available, the trials.length elements will
+ * be returned.
+ *
+ */
+ data_collection.last = function(n) {
+ if (typeof n == 'undefined') { n = 1 }
+ if (n < 1) {
+ throw `You must query with a positive nonzero integer. Please use a
+ different value for n.`;
+ }
+ if (trials.length == 0) return DataCollection([]);
+ if (n > trials.length) n = trials.length;
+ return DataCollection(trials.slice(trials.length - n, trials.length));
+ }
+
+ data_collection.values = function(){
+ return trials;
+ }
+
+ data_collection.count = function(){
+ return trials.length;
+ }
+
+ data_collection.readOnly = function(){
+ return DataCollection(jsPsych.utils.deepCopy(trials));
+ }
+
+ data_collection.addToAll = function(properties){
+ for (var i = 0; i < trials.length; i++) {
+ for (var key in properties) {
+ trials[i][key] = properties[key];
+ }
+ }
+ return data_collection;
+ }
+
+ data_collection.addToLast = function(properties){
+ if(trials.length != 0){
+ for (var key in properties) {
+ trials[trials.length-1][key] = properties[key];
+ }
+ }
+ return data_collection;
+ }
+
+ data_collection.filter = function(filters){
+ // [{p1: v1, p2:v2}, {p1:v2}]
+ // {p1: v1}
+ if(!Array.isArray(filters)){
+ var f = jsPsych.utils.deepCopy([filters]);
+ } else {
+ var f = jsPsych.utils.deepCopy(filters);
+ }
+
+ var filtered_data = [];
+ for(var x=0; x < trials.length; x++){
+ var keep = false;
+ for(var i=0; iclick to download');
+ document.getElementById('jspsych-download-as-text-link').click();
+ }
+
+ //
+ // A few helper functions to handle data format conversion
+ //
+
+ // this function based on code suggested by StackOverflow users:
+ // http://stackoverflow.com/users/64741/zachary
+ // http://stackoverflow.com/users/317/joseph-sturtevant
+
+ function JSON2CSV(objArray) {
+ var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray;
+ var line = '';
+ var result = '';
+ var columns = [];
+
+ var i = 0;
+ for (var j = 0; j < array.length; j++) {
+ for (var key in array[j]) {
+ var keyString = key + "";
+ keyString = '"' + keyString.replace(/"/g, '""') + '",';
+ if (!columns.includes(key)) {
+ columns[i] = key;
+ line += keyString;
+ i++;
+ }
+ }
+ }
+
+ line = line.slice(0, -1);
+ result += line + '\r\n';
+
+ for (var i = 0; i < array.length; i++) {
+ var line = '';
+ for (var j = 0; j < columns.length; j++) {
+ var value = (typeof array[i][columns[j]] === 'undefined') ? '' : array[i][columns[j]];
+ if(typeof value == 'object') {
+ value = JSON.stringify(value);
+ }
+ var valueString = value + "";
+ line += '"' + valueString.replace(/"/g, '""') + '",';
+ }
+
+ line = line.slice(0, -1);
+ result += line + '\r\n';
+ }
+
+ return result;
+ }
+
+ // this function is modified from StackOverflow:
+ // http://stackoverflow.com/posts/3855394
+
+ function getQueryString() {
+ var a = window.location.search.substr(1).split('&');
+ if (a == "") return {};
+ var b = {};
+ for (var i = 0; i < a.length; ++i)
+ {
+ var p=a[i].split('=', 2);
+ if (p.length == 1)
+ b[p[0]] = "";
+ else
+ b[p[0]] = decodeURIComponent(p[1].replace(/\+/g, " "));
+ }
+ return b;
+ }
+
+ return module;
+
+})();
+
+jsPsych.turk = (function() {
+
+ var module = {};
+
+ // core.turkInfo gets information relevant to mechanical turk experiments. returns an object
+ // containing the workerID, assignmentID, and hitID, and whether or not the HIT is in
+ // preview mode, meaning that they haven't accepted the HIT yet.
+ module.turkInfo = function() {
+
+ var turk = {};
+
+ var param = function(url, name) {
+ name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]");
+ var regexS = "[\\?&]" + name + "=([^]*)";
+ var regex = new RegExp(regexS);
+ var results = regex.exec(url);
+ return (results == null) ? "" : results[1];
+ };
+
+ var src = param(window.location.href, "assignmentId") ? window.location.href : document.referrer;
+
+ var keys = ["assignmentId", "hitId", "workerId", "turkSubmitTo"];
+ keys.map(
+
+ function(key) {
+ turk[key] = unescape(param(src, key));
+ });
+
+ turk.previewMode = (turk.assignmentId == "ASSIGNMENT_ID_NOT_AVAILABLE");
+
+ turk.outsideTurk = (!turk.previewMode && turk.hitId === "" && turk.assignmentId == "" && turk.workerId == "")
+
+ turk_info = turk;
+
+ return turk;
+
+ };
+
+ // core.submitToTurk will submit a MechanicalTurk ExternalHIT type
+ module.submitToTurk = function(data) {
+
+ var turkInfo = jsPsych.turk.turkInfo();
+ var assignmentId = turkInfo.assignmentId;
+ var turkSubmitTo = turkInfo.turkSubmitTo;
+
+ if (!assignmentId || !turkSubmitTo) return;
+
+ var dataString = [];
+
+ for (var key in data) {
+
+ if (data.hasOwnProperty(key)) {
+ dataString.push(key + "=" + escape(data[key]));
+ }
+ }
+
+ dataString.push("assignmentId=" + assignmentId);
+
+ var url = turkSubmitTo + "/mturk/externalSubmit?" + dataString.join("&");
+
+ window.location.href = url;
+ };
+
+ return module;
+
+})();
+
+jsPsych.randomization = (function() {
+
+ var module = {};
+
+ module.repeat = function(array, repetitions, unpack) {
+
+ var arr_isArray = Array.isArray(array);
+ var rep_isArray = Array.isArray(repetitions);
+
+ // if array is not an array, then we just repeat the item
+ if (!arr_isArray) {
+ if (!rep_isArray) {
+ array = [array];
+ repetitions = [repetitions];
+ } else {
+ repetitions = [repetitions[0]];
+ console.log('Unclear parameters given to randomization.repeat. Multiple set sizes specified, but only one item exists to sample. Proceeding using the first set size.');
+ }
+ } else {
+ if (!rep_isArray) {
+ var reps = [];
+ for (var i = 0; i < array.length; i++) {
+ reps.push(repetitions);
+ }
+ repetitions = reps;
+ } else {
+ if (array.length != repetitions.length) {
+ console.warning('Unclear parameters given to randomization.repeat. Items and repetitions are unequal lengths. Behavior may not be as expected.');
+ // throw warning if repetitions is too short, use first rep ONLY.
+ if (repetitions.length < array.length) {
+ var reps = [];
+ for (var i = 0; i < array.length; i++) {
+ reps.push(repetitions);
+ }
+ repetitions = reps;
+ } else {
+ // throw warning if too long, and then use the first N
+ repetitions = repetitions.slice(0, array.length);
+ }
+ }
+ }
+ }
+
+ // should be clear at this point to assume that array and repetitions are arrays with == length
+ var allsamples = [];
+ for (var i = 0; i < array.length; i++) {
+ for (var j = 0; j < repetitions[i]; j++) {
+ if(array[i] == null || typeof array[i] != 'object'){
+ allsamples.push(array[i]);
+ } else {
+ allsamples.push(Object.assign({}, array[i]));
+ }
+
+ }
+ }
+
+ var out = shuffle(allsamples);
+
+ if (unpack) {
+ out = unpackArray(out);
+ }
+
+ return out;
+ }
+
+ module.shuffle = function(arr) {
+ if(!Array.isArray(arr)){
+ console.error('Argument to jsPsych.randomization.shuffle() must be an array.')
+ }
+ return shuffle(arr);
+ }
+
+ module.shuffleNoRepeats = function(arr, equalityTest) {
+ if(!Array.isArray(arr)){
+ console.error('First argument to jsPsych.randomization.shuffleNoRepeats() must be an array.')
+ }
+ if(typeof equalityTest !== 'undefined' && typeof equalityTest !== 'function'){
+ console.error('Second argument to jsPsych.randomization.shuffleNoRepeats() must be a function.')
+ }
+ // define a default equalityTest
+ if (typeof equalityTest == 'undefined') {
+ equalityTest = function(a, b) {
+ if (a === b) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+ }
+
+ var random_shuffle = shuffle(arr);
+ for (var i = 0; i < random_shuffle.length - 1; i++) {
+ if (equalityTest(random_shuffle[i], random_shuffle[i + 1])) {
+ // neighbors are equal, pick a new random neighbor to swap (not the first or last element, to avoid edge cases)
+ var random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1;
+ // test to make sure the new neighbor isn't equal to the old one
+ while (
+ equalityTest(random_shuffle[i + 1], random_shuffle[random_pick]) ||
+ (equalityTest(random_shuffle[i + 1], random_shuffle[random_pick + 1]) || equalityTest(random_shuffle[i + 1], random_shuffle[random_pick - 1]))
+ ) {
+ random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1;
+ }
+ var new_neighbor = random_shuffle[random_pick];
+ random_shuffle[random_pick] = random_shuffle[i + 1];
+ random_shuffle[i + 1] = new_neighbor;
+ }
+ }
+
+ return random_shuffle;
+ }
+
+ module.shuffleAlternateGroups = function(arr_groups, random_group_order){
+ if(typeof random_group_order == 'undefined'){
+ random_group_order = false;
+ }
+
+ var n_groups = arr_groups.length;
+ if(n_groups == 1){
+ console.warn('jsPsych.randomization.shuffleAlternateGroups was called with only one group. Defaulting to simple shuffle.');
+ return(module.shuffle(arr_groups[0]));
+ }
+
+ var group_order = [];
+ for(var i=0; i arr.length) {
+ console.error("Cannot take a sample " +
+ "larger than the size of the set of items to sample.");
+ }
+ return jsPsych.randomization.shuffle(arr).slice(0,size);
+ }
+
+ module.sampleWithReplacement = function(arr, size, weights) {
+ if(!Array.isArray(arr)){
+ console.error("First argument to jsPsych.randomization.sampleWithReplacement() must be an array")
+ }
+
+ var normalized_weights = [];
+ if(typeof weights !== 'undefined'){
+ if(weights.length !== arr.length){
+ console.error('The length of the weights array must equal the length of the array '+
+ 'to be sampled from.');
+ }
+ var weight_sum = 0;
+ for(var i=0; i cumulative_weights[index]) { index++; }
+ samp.push(arr[index]);
+ }
+ return samp;
+ }
+
+ module.factorial = function(factors, repetitions, unpack) {
+
+ var factorNames = Object.keys(factors);
+
+ var factor_combinations = [];
+
+ for (var i = 0; i < factors[factorNames[0]].length; i++) {
+ factor_combinations.push({});
+ factor_combinations[i][factorNames[0]] = factors[factorNames[0]][i];
+ }
+
+ for (var i = 1; i < factorNames.length; i++) {
+ var toAdd = factors[factorNames[i]];
+ var n = factor_combinations.length;
+ for (var j = 0; j < n; j++) {
+ var base = factor_combinations[j];
+ for (var k = 0; k < toAdd.length; k++) {
+ var newpiece = {};
+ newpiece[factorNames[i]] = toAdd[k];
+ factor_combinations.push(Object.assign({}, base, newpiece));
+ }
+ }
+ factor_combinations.splice(0, n);
+ }
+
+ repetitions = (typeof repetitions === 'undefined') ? 1 : repetitions;
+ var with_repetitions = module.repeat(factor_combinations, repetitions, unpack);
+
+ return with_repetitions;
+ }
+
+ module.randomID = function(length){
+ var result = '';
+ var length = (typeof length == 'undefined') ? 32 : length;
+ var chars = '0123456789abcdefghjklmnopqrstuvwxyz';
+ for(var i = 0; i= 0) {
+ k = n;
+ } else {
+ k = len + n;
+ if (k < 0) {k = 0;}
+ }
+ var currentElement;
+ while (k < len) {
+ currentElement = O[k];
+ if (searchElement === currentElement ||
+ (searchElement !== searchElement && currentElement !== currentElement)) { // NaN !== NaN
+ return true;
+ }
+ k++;
+ }
+ return false;
+ };
+}
+
+// polyfill for Array.isArray
+if (!Array.isArray) {
+ Array.isArray = function(arg) {
+ return Object.prototype.toString.call(arg) === '[object Array]';
+ };
+}
diff --git a/web/jsPsych/license.txt b/web/jsPsych/license.txt
new file mode 100644
index 00000000..b8cc7f07
--- /dev/null
+++ b/web/jsPsych/license.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2014-2018 Joshua R. de Leeuw
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/web/src/jsPsychWebSocket.js b/web/src/jsPsychWebSocket.js
new file mode 100644
index 00000000..1351ef43
--- /dev/null
+++ b/web/src/jsPsychWebSocket.js
@@ -0,0 +1,91 @@
+var refreshCount = 0;
+var startTime;
+
+// Store the incoming feedback (i.e. classification results)
+var FeedbackStatus = {
+ connected : false,
+ doFeedback: false,
+ error : "No Error",
+ message : "Begin Message",
+ runId : 0,
+ trId : 0,
+ val : 0,
+};
+
+// Collect the subject's keyboard responses
+var ResponseQueue = [];
+
+// setInterval(randomNumber, 1000)
+
+function createWebSocket() {
+ var wsProtocol = 'wss://'
+ if (location.protocol == 'http:') {
+ wsProtocol = 'ws://'
+ }
+ var wsSubjURL = wsProtocol + location.hostname + ':' + location.port + '/wsSubject'
+ console.log(wsSubjURL)
+ var webSocket = new WebSocket(wsSubjURL);
+ webSocket.onopen = (openEvent) => {
+ FeedbackStatus.connected = true
+ console.log("WebSocket OPEN: ");
+ };
+ webSocket.onclose = (closeEvent) => {
+ FeedbackStatus.connected = false
+ console.log("WebSocket CLOSE: ");
+ };
+ webSocket.onerror = (errorEvent) => {
+ FeedbackStatus.error = JSON.stringify(errorEvent, null, 4)
+ console.log("WebSocket ERROR: " + JSON.stringify(errorEvent, null, 4));
+ };
+ webSocket.onmessage = (messageEvent) => {
+ var wsMsg = messageEvent.data;
+ var request = JSON.parse(wsMsg)
+ var reqClass = request['class']
+ var reqCmd = request['attribute']
+ var reqArgs = request['args']
+ var retVal = true
+ var retCode = 200
+ if (reqCmd == 'setResult') {
+ var runId = reqArgs[0]
+ var trId = reqArgs[1]
+ var val = reqArgs[2]
+ var onsetTimeDelayMs = reqArgs[3]
+ FeedbackStatus.doFeedback = true
+ FeedbackStatus.runId = runId
+ FeedbackStatus.trId = trId
+ FeedbackStatus.val = val
+ FeedbackStatus.message = "BrainState: " + FeedbackStatus.val
+ // Trigger the rtEvent to end the previous trial and start this next one
+ let event = new CustomEvent("rtEvent", {detail: {onsetTimeDelayMs: onsetTimeDelayMs}});
+ document.dispatchEvent(event);
+ } else if (reqCmd == 'setMessage') {
+ FeedbackStatus.doFeedback = false // display message instead
+ FeedbackStatus.message = reqArgs[0]
+ let event = new CustomEvent("rtEvent", {detail: {onsetTimeDelayMs: 0}});
+ document.dispatchEvent(event);
+ } else if (reqCmd = 'getAllResponses') {
+ // Dequeue and return all data from ResponseQueue
+ retVal = []
+ while (ResponseQueue.length > 0) {
+ var entry = ResponseQueue.shift();
+ retVal.push(entry);
+ }
+ } else {
+ errStr = "Unknown message type: " + reqCmd
+ FeedbackStatus.error = errStr
+ console.log(errStr)
+ retVal = errStr
+ retCode = 400
+ }
+ // Send the response
+ var response = request;
+ delete response['data']
+ delete response['args']
+ delete response['kwargs']
+ response['dataSerialization'] = 'json'
+ response['data'] = btoa(JSON.stringify(retVal))
+ response['status'] = retCode
+ webSocket.send(JSON.stringify(response))
+ };
+ this.webSocket = webSocket
+}
diff --git a/web/src/jspsych-plugin-realtime-response.js b/web/src/jspsych-plugin-realtime-response.js
new file mode 100644
index 00000000..3db9c120
--- /dev/null
+++ b/web/src/jspsych-plugin-realtime-response.js
@@ -0,0 +1,131 @@
+/**
+ * jspsych-brain-realtime-response
+ * Sebastian Michelmann
+ *
+ * plugin for a trial that ends on an external 'rtEvent' event
+*
+ **/
+
+jsPsych.plugins["brain-realtime-response"] = (function() {
+
+ var plugin = {};
+
+ // TODO (Improvement) ideally the plugin should have a parameter that is reserved for an element that
+ // the listener should be attached to! (e.g. the websocket)
+ plugin.info = {
+ name: 'brain-realtime-response',
+ description: '',
+ parameters: {
+ trial_duration: {
+ type: jsPsych.plugins.parameterType.INT,
+ pretty_name: 'Trial duration',
+ default: null,
+ description: 'The minimal duration of the trial in milliseconds.'
+ },
+ canvas_size: {
+ type: jsPsych.plugins.parameterType.INT,
+ array: true,
+ pretty_name: 'Canvas size',
+ default: [500, 500],
+ description: 'Array containing the height (first value) and width (second value) of the canvas element.'
+ },
+ choices: {
+ type: jsPsych.plugins.parameterType.KEY,
+ array: true,
+ pretty_name: 'Choices',
+ default: jsPsych.NO_KEYS,
+ description: 'The keys the subject is allowed to press to respond to the stimulus.'
+ },
+ }
+ }
+
+ plugin.trial = function(display_element, trial) {
+ var start_time = performance.now();
+ var websocketEvent_time = null;
+ // store subject's keyboard response
+ var response = {
+ rt: null, // response time
+ key: null, // key pressed
+ };
+
+ // add event listeners to document
+ document.addEventListener('rtEvent', event_handler);
+
+ // function to handle responses by the subject
+ var after_response = function(info) {
+ // only record the first response
+ if (response.key == null) {
+ response = info;
+ }
+ };
+
+ // start the response listener
+ var keyboardListener = null;
+ if (trial.choices != jsPsych.NO_KEYS) {
+ keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({
+ callback_function: after_response,
+ valid_responses: trial.choices,
+ rt_method: 'performance',
+ persist: false,
+ allow_held_key: false,
+ });
+ }
+
+ // function to wrap up when the event has arrived (not finished if it's too early)
+ function event_handler(e){
+ // remove the listener first!
+ // TODO - maybe don't remove listener? - how would we cancel a previous set timeout?
+ document.removeEventListener('rtEvent', event_handler);
+
+ // onsetTimeDelay is provided as part of the setResult() call from
+ // the researcher's script. It specifies the time to wait before
+ // showing the next feedback stimulus (for example to align to a TR).
+ // The event_handler function takes a parameter dictionary where the
+ // "detail" field can contain caller defined information.
+ var onsetTimeDelayMs = 0;
+ if ("detail" in e) {
+ if ("onsetTimeDelayMs" in e.detail) {
+ onsetTimeDelayMs = e.detail.onsetTimeDelayMs;
+ }
+ }
+
+ // store when the rtEvent event happened
+ websocketEvent_time = performance.now() - start_time;
+
+ if (onsetTimeDelayMs > 0) {
+ // setTimeout input is time in miliseconds
+ jsPsych.pluginAPI.setTimeout(end_trial, onsetTimeDelayMs)
+ } else{
+ //otherwise we just end the trial now
+ end_trial()
+ }
+ }
+
+ // function to end trial when it is time
+ function end_trial() {
+ if (keyboardListener != null) {
+ jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener)
+ }
+
+ var stimulus_duration = performance.now() - start_time;
+
+ // gather the data to store for the trial
+ var trial_data = {
+ "key_pressed": response.key,
+ "key_response_time": response.rt,
+ "stimulus_duration": stimulus_duration,
+ "stimulus_onset_time": start_time,
+ "websocket_event_time": websocketEvent_time,
+ };
+
+ // clear the display
+ display_element.innerHTML = '';
+ // clear all timeouts
+ jsPsych.pluginAPI.clearAllTimeouts();
+ // finish
+ jsPsych.finishTrial(trial_data);
+ };
+ };
+
+ return plugin;
+})();