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.

'; + core.getDisplayElement().innerHTML = msg; + fail(); + return; + } + } + + // GO? + if(clear){ success(); } + } + + function drawProgressBar(msg) { + document.querySelector('.jspsych-display-element').insertAdjacentHTML('afterbegin', + '
'+ + ''+ + msg+ + ''+ + '
'+ + '
'+ + '
'); + } + + 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; i