diff --git a/.gitignore b/.gitignore index fc413cab..398b86fe 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ build/ # project specific data/** metadata/** +incomplete/** reject/** diff --git a/README.rst b/README.rst index 1a8a8a83..af7e0336 100644 --- a/README.rst +++ b/README.rst @@ -4,31 +4,27 @@ .. image:: https://img.shields.io/github/license/mashape/apistatus.svg :target: https://github.com/nivlab/NivLink/blob/master/LICENSE +.. image:: https://zenodo.org/badge/182183266.svg + :target: https://zenodo.org/badge/latestdoi/182183266 + NivTurk ======= Niv lab tools for securely serving and storing data from online computational psychiatry experiments. -Quickstart -^^^^^^^^^^ - -The following is the minimal set of commands needed to get started with NivTurk (assuming you have already a virtual machine with python 3.6+ installed): - -.. code-block:: bash - - ssh @.princeton.edu - git clone https://github.com/nivlab/nivturk.git - cd nivturk - pip install -r requirements.txt - gunicorn -b 0.0.0.0:9000 -w 4 app:app - - Documentation ^^^^^^^^^^^^^ For details on how to serve your experiment, how the code is organized, and how data is stored, please see the `Documentation `_. +Citation +^^^^^^^^ + +If you use this library in academic work, please cite the following: + + | Samuel Zorowitz & Daniel Bennett. (2022). NivTurk (v1.2-prolific). Zenodo. https://doi.org/10.5281/zenodo.6609218 + Acknowledgements ^^^^^^^^^^^^^^^^ NivTurk was developed with support from the National Center for Advancing Translational Sciences (NCATS), a component of the National Institute of Health (NIH), under award number UL1TR003017. diff --git a/app/__init__.py b/app/__init__.py index 9992ac32..a96141d1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,9 +1,9 @@ -import os, sys, configparser, warnings +import os, sys, re, configparser, warnings from flask import (Flask, redirect, render_template, request, session, url_for) from app import consent, alert, experiment, complete, error from .io import write_metadata from .utils import gen_code -__version__ = '1.1' +__version__ = '1.2' ## Define root directory. ROOT_DIR = os.path.dirname(os.path.realpath(__file__)) @@ -17,6 +17,8 @@ if not os.path.isdir(data_dir): os.makedirs(data_dir) meta_dir = os.path.join(ROOT_DIR, cfg['IO']['METADATA']) if not os.path.isdir(meta_dir): os.makedirs(meta_dir) +incomplete_dir = os.path.join(ROOT_DIR, cfg['IO']['INCOMPLETE']) +if not os.path.isdir(incomplete_dir): os.makedirs(incomplete_dir) reject_dir = os.path.join(ROOT_DIR, cfg['IO']['REJECT']) if not os.path.isdir(reject_dir): os.makedirs(reject_dir) @@ -30,6 +32,9 @@ if secret_key == "PLEASE_CHANGE_THIS": warnings.warn("WARNING: Flask password is currently default. This should be changed prior to production.") +## Check restart mode; if true, participants can restart experiment. +allow_restart = cfg['FLASK'].getboolean('ALLOW_RESTART') + ## Initialize Flask application. app = Flask(__name__) app.secret_key = secret_key @@ -52,7 +57,9 @@ def index(): ## Store directories in session object. session['data'] = data_dir session['metadata'] = meta_dir + session['incomplete'] = incomplete_dir session['reject'] = reject_dir + session['allow_restart'] = allow_restart ## Record incoming metadata. info = dict( @@ -72,47 +79,23 @@ def index(): version = request.user_agent.version, # User metadata ) - ## Case 1: workerId absent. + ## Case 1: workerId absent form URL. if info['workerId'] is None: ## Redirect participant to error (missing workerId). return redirect(url_for('error.error', errornum=1000)) - ## Case 2: mobile user. + ## Case 2: mobile / tablet user. elif info['platform'] in ['android','iphone','ipad','wii']: ## Redirect participant to error (platform error). return redirect(url_for('error.error', errornum=1001)) - ## Case 3: repeat visit, preexisting log but no session data. - elif not 'workerId' in session and info['workerId'] in os.listdir(meta_dir): - - ## Consult log file. - with open(os.path.join(session['metadata'], info['workerId']),'r') as f: - logs = f.read() - - ## Case 3a: previously started experiment. - if 'experiment' in logs: - - ## Update metadata. - session['workerId'] = info['workerId'] - session['ERROR'] = '1004: Suspected incognito user.' - session['complete'] = 'error' - write_metadata(session, ['ERROR','complete'], 'a') - - ## Redirect participant to error (previous participation). - return redirect(url_for('error.error', errornum=1004)) - - ## Case 3b: no previous experiment starts. - else: - - ## Update metadata. - for k, v in info.items(): session[k] = v - session['WARNING'] = "Assigned new subId." - write_metadata(session, ['subId','WARNING'], 'a') + ## Case 3: previous complete. + elif 'complete' in session: - ## Redirect participant to consent form. - return redirect(url_for('consent.consent')) + ## Redirect participant to complete page. + return redirect(url_for('complete.complete')) ## Case 4: repeat visit, manually changed workerId. elif 'workerId' in session and session['workerId'] != info['workerId']: @@ -125,25 +108,46 @@ def index(): ## Redirect participant to error (unusual activity). return redirect(url_for('error.error', errornum=1005)) - ## Case 5: repeat visit, previously completed experiment. - elif 'complete' in session: + ## Case 5: repeat visit, preexisting activity. + elif 'workerId' in session: - ## Update metadata. - session['WARNING'] = "Revisited home." - write_metadata(session, ['WARNING'], 'a') + ## Redirect participant to consent form. + return redirect(url_for('consent.consent')) - ## Redirect participant to complete page. - return redirect(url_for('complete.complete')) + ## Case 6: repeat visit, preexisting log but no session data. + elif not 'workerId' in session and info['workerId'] in os.listdir(meta_dir): - ## Case 6: repeat visit, preexisting activity. - elif 'workerId' in session: + ## Parse log file. + with open(os.path.join(session['metadata'], info['workerId']), 'r') as f: + logs = f.read() + + ## Extract subject ID. + info['subId'] = re.search('subId\t(.*)\n', logs).group(1) + + ## Check for previous consent. + consent = re.search('consent\t(.*)\n', logs) + if consent and consent.group(1) == 'True': info['consent'] = True # consent = true + elif consent and consent.group(1) == 'False': info['consent'] = False # consent = false + elif consent: info['consent'] = consent.group(1) # consent = bot + + ## Check for previous experiment. + experiment = re.search('experiment\t(.*)\n', logs) + if experiment: info['experiment'] = experiment.group(1) + + ## Check for previous complete. + complete = re.search('complete\t(.*)\n', logs) + if complete: info['complete'] = complete.group(1) ## Update metadata. - session['WARNING'] = "Revisited home." - write_metadata(session, ['WARNING'], 'a') + for k, v in info.items(): session[k] = v - ## Redirect participant to consent form. - return redirect(url_for('consent.consent')) + ## Redirect participant as appropriate. + if 'complete' in session: + return redirect(url_for('complete.complete')) + elif 'experiment' in session: + return redirect(url_for('experiment.experiment')) + else: + return redirect(url_for('consent.consent')) ## Case 7: first visit, workerId present. else: diff --git a/app/alert.py b/app/alert.py index 4003a9bc..604e7bce 100644 --- a/app/alert.py +++ b/app/alert.py @@ -17,21 +17,13 @@ def alert(): ## Case 1: previously completed experiment. elif 'complete' in session: - ## Update metadata. - session['WARNING'] = "Revisited alert page." - write_metadata(session, ['WARNING'], 'a') - ## Redirect participant to complete page. return redirect(url_for('complete.complete')) ## Case 2: repeat visit. elif 'alert' in session: - ## Update participant metadata. - session['WARNING'] = "Revisited alert page." - write_metadata(session, ['WARNING'], 'a') - - ## Redirect participant to error (previous participation). + ## Redirect participant to experiment. return redirect(url_for('experiment.experiment')) ## Case 3: first visit. diff --git a/app/app.ini b/app/app.ini index 001ecfdc..66e24281 100644 --- a/app/app.ini +++ b/app/app.ini @@ -1,10 +1,14 @@ [FLASK] # Flask secret key for encrypting session objects -# Suggested: get key from https://randomkeygen.com +# Recommended: get key from https://randomkeygen.com SECRET_KEY = PLEASE_CHANGE_THIS -# Toggle debug mode (allow repeat visits from same session) +# Allow participants to restart experiments +# Accepts true or false +ALLOW_RESTART = false + +# Toggle debug mode (session cookies cleared on start) # Accepts true or false DEBUG = true @@ -16,5 +20,8 @@ METADATA = ../metadata # Path to data folder [default: ../data] DATA = ../data +# Path to incomplete data folder [default: ../incomplete] +INCOMPLETE = ../incomplete + # Path to reject folder [default: ../reject] REJECT = ../reject diff --git a/app/complete.py b/app/complete.py index db436160..60fd0832 100644 --- a/app/complete.py +++ b/app/complete.py @@ -11,7 +11,7 @@ def complete(): ## Access query string. query_info = request.args - ## Confirm all TurkPrime metadata present. + ## Confirm all CloudResearch metadata present. fields = ['workerId','assignmentId','hitId','a','tp_a','b','tp_b','c','tp_c'] all_fields = all([f in query_info for f in fields]) @@ -21,38 +21,40 @@ def complete(): ## Redirect participant to error (missing workerId). return redirect(url_for('error.error', errornum=1000)) - ## Case 1: visit complete page with previous rejection. - elif session.get('complete') == 'reject': + ## Case 1: visit complete page without previous completion. + elif 'complete' not in session: - ## Update metadata. - session['WARNING'] = "Revisited complete." - write_metadata(session, ['WARNING'], 'a') + ## Flag experiment as complete. + session['ERROR'] = "1005: Visited complete page before completion." + session['complete'] = 'reject' + write_metadata(session, ['ERROR','complete'], 'a') ## Redirect participant to error (unusual activity). return redirect(url_for('error.error', errornum=1005)) - ## Case 2: visit complete page with previous error. - elif session.get('complete') == 'error': - - ## Update metadata. - session['WARNING'] = "Revisited complete." - write_metadata(session, ['WARNING'], 'a') + ## Case 2: visit complete page with previous rejection. + elif session['complete'] == 'reject': ## Redirect participant to error (unusual activity). return redirect(url_for('error.error', errornum=1005)) - ## Case 3: visit complete page but missing metadata. - elif session.get('complete') == 'success' and not all_fields: + ## Case 3: visit complete page with previous error. + elif session['complete'] == 'error': + + ## Determine error code. + errornum = 1002 if not session['consent'] else 1005 + + ## Redirect participant to error (unusual activity). + return redirect(url_for('error.error', errornum=errornum)) - ## Update metadata. - session['WARNING'] = "Revisited complete." - write_metadata(session, ['WARNING'], 'a') + ## Case 4: visit complete page with previous success. + elif session['complete'] == 'success' and not all_fields: ## Redirect participant with complete metadata. url = "/complete?workerId=%s&assignmentId=%s&hitId=%s&a=%s&tp_a=%s&b=%s&tp_b=%s&c=%s&tp_c=%s" %(session['workerId'], session['assignmentId'], session['hitId'], session['a'], session['tp_a'], session['b'], session['tp_b'], session['c'], session['tp_c']) return redirect(url) - ## Case 4: all else. + ## Case 5: all else. else: ## Redirect participant with completion code. diff --git a/app/consent.py b/app/consent.py index fa6bbd1a..93f17a5f 100644 --- a/app/consent.py +++ b/app/consent.py @@ -17,10 +17,6 @@ def consent(): ## Case 1: previously completed experiment. elif 'complete' in session: - ## Update metadata. - session['WARNING'] = "Revisited consent page." - write_metadata(session, ['WARNING'], 'a') - ## Redirect participant to complete page. return redirect(url_for('complete.complete')) @@ -33,30 +29,18 @@ def consent(): ## Case 3: repeat visit, previous bot-detection. elif session['consent'] == 'BOT': - ## Update participant metadata. - session['WARNING'] = "Revisited consent form." - write_metadata(session, ['WARNING'], 'a') - ## Redirect participant to error (unusual activity). return redirect(url_for('error.error', errornum=1005)) ## Case 4: repeat visit, previous non-consent. elif session['consent'] == False: - ## Update participant metadata. - session['WARNING'] = "Revisited consent form." - write_metadata(session, ['WARNING'], 'a') - ## Redirect participant to error (decline consent). return redirect(url_for('error.error', errornum=1002)) ## Case 5: repeat visit, previous consent. else: - ## Update participant metadata. - session['WARNING'] = "Revisited consent form." - write_metadata(session, ['WARNING'], 'a') - ## Redirect participant to alert page. return redirect(url_for('alert.alert')) @@ -73,9 +57,8 @@ def consent_post(): ## Update participant metadata. session['consent'] = 'BOT' - session['experiment'] = False # Prevents incognito users session['complete'] = 'error' - write_metadata(session, ['consent','experiment','complete'], 'a') + write_metadata(session, ['consent','complete'], 'a') ## Redirect participant to error (unusual activity). return redirect(url_for('error.error', errornum=1005)) @@ -94,6 +77,7 @@ def consent_post(): ## Update participant metadata. session['consent'] = False + session['complete'] = 'error' write_metadata(session, ['consent'], 'a') ## Redirect participant to error (decline consent). diff --git a/app/experiment.py b/app/experiment.py index 338a0257..7cd59f46 100644 --- a/app/experiment.py +++ b/app/experiment.py @@ -17,15 +17,11 @@ def experiment(): ## Case 1: previously completed experiment. elif 'complete' in session: - ## Update metadata. - session['WARNING'] = "Revisited experiment page." - write_metadata(session, ['WARNING'], 'a') - ## Redirect participant to complete page. return redirect(url_for('complete.complete')) ## Case 2: repeat visit. - elif 'experiment' in session: + elif not session['allow_restart'] and 'experiment' in session: ## Update participant metadata. session['ERROR'] = "1004: Revisited experiment." @@ -65,6 +61,29 @@ def pass_message(): ## https://developer.mozilla.org/en-US/docs/Web/HTTP/Status return ('', 200) +@bp.route('/incomplete_save', methods=['POST']) +def incomplete_save(): + """Save incomplete jsPsych dataset to disk.""" + + if request.is_json: + + ## Retrieve jsPsych data. + JSON = request.get_json() + + ## Save jsPsch data to disk. + write_data(session, JSON, method='incomplete') + + ## Flag partial data saving. + session['MESSAGE'] = 'incomplete dataset saved' + write_metadata(session, ['MESSAGE'], 'a') + + ## DEV NOTE: + ## This function returns the HTTP response status code: 200 + ## Code 200 signifies the POST request has succeeded. + ## For a full list of status codes, see: + ## https://developer.mozilla.org/en-US/docs/Web/HTTP/Status + return ('', 200) + @bp.route('/redirect_success', methods = ['POST']) def redirect_success(): """Save complete jsPsych dataset to disk.""" diff --git a/app/io.py b/app/io.py index 26b452a8..636d99c2 100644 --- a/app/io.py +++ b/app/io.py @@ -41,5 +41,7 @@ def write_data(session, json, method='pass'): fout = os.path.join(session['data'], '%s.json' %session['subId']) elif method == 'reject': fout = os.path.join(session['reject'], '%s.json' %session['subId']) + elif method == 'incomplete': + fout = os.path.join(session['incomplete'], '%s.json' %session['subId']) with open(fout, 'w') as f: f.write(json) diff --git a/app/static/js/nivturk-plugins.js b/app/static/js/nivturk-plugins.js index 1deae1a3..003230b8 100644 --- a/app/static/js/nivturk-plugins.js +++ b/app/static/js/nivturk-plugins.js @@ -14,6 +14,22 @@ function pass_message(msg) { } +// Save an incomplete dataset. +function incomplete_save() { + + $.ajax({ + url: "/incomplete_save", + method: 'POST', + data: JSON.stringify(jsPsych.data.get().json()), + contentType: "application/json; charset=utf-8", + }).done(function(data, textStatus, jqXHR) { + // do nothing + }).fail(function(error) { + // do nothing + }); + +} + // Successful completion of experiment: redirect with completion code. function redirect_success(workerId, assignmentId, hitId, a, tp_a, b, tp_b, c, tp_c) { @@ -28,7 +44,7 @@ function redirect_success(workerId, assignmentId, hitId, a, tp_a, b, tp_b, c, tp }).done(function(data, textStatus, jqXHR) { window.location.replace(url); }).fail(function(error) { - console.log(error); + window.location.replace(url); }); } @@ -47,7 +63,7 @@ function redirect_reject(error) { }).done(function(data, textStatus, jqXHR) { window.location.replace(url); }).fail(function(error) { - console.log(error); + window.location.replace(url); }); } @@ -65,7 +81,7 @@ function redirect_error(error) { }).done(function(data, textStatus, jqXHR) { window.location.replace(url); }).fail(function(error) { - console.log(error); + window.location.replace(url); }); } diff --git a/app/static/lib/jspsych-6.3.1/css/jspsych.css b/app/static/lib/jspsych-6.3.1/css/jspsych.css deleted file mode 100644 index 3b6d1774..00000000 --- a/app/static/lib/jspsych-6.3.1/css/jspsych.css +++ /dev/null @@ -1,206 +0,0 @@ -/* - * 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/app/static/lib/jspsych-6.3.1/extensions/jspsych-ext-webgazer.js b/app/static/lib/jspsych-6.3.1/extensions/jspsych-ext-webgazer.js deleted file mode 100644 index f8ffe668..00000000 --- a/app/static/lib/jspsych-6.3.1/extensions/jspsych-ext-webgazer.js +++ /dev/null @@ -1,265 +0,0 @@ -jsPsych.extensions['webgazer'] = (function () { - - var extension = {}; - - // private state for the extension - // extension authors can define public functions to interact - // with the state. recommend not exposing state directly - // so that state manipulations are checked. - var state = {}; - - // required, will be called at jsPsych.init - // should return a Promise - extension.initialize = function (params) { - // setting default values for params if not defined - params.round_predictions = typeof params.round_predictions === 'undefined' ? true : params.round_predictions; - params.auto_initialize = typeof params.auto_initialize === 'undefined' ? false : params.auto_initialize; - params.sampling_interval = typeof params.sampling_interval === 'undefined' ? 34 : params.sampling_interval; - - return new Promise(function (resolve, reject) { - if (typeof params.webgazer === 'undefined') { - if (window.webgazer) { - state.webgazer = window.webgazer; - } else { - reject(new Error('Webgazer extension failed to initialize. webgazer.js not loaded. Load webgazer.js before calling jsPsych.init()')); - } - } else { - state.webgazer = params.webgazer; - } - - // sets up event handler for webgazer data - state.webgazer.setGazeListener(handleGazeDataUpdate); - - // default to threadedRidge regression - // NEVER MIND... kalman filter is too useful. - //state.webgazer.workerScriptURL = 'js/webgazer/ridgeWorker.mjs'; - //state.webgazer.setRegression('threadedRidge'); - //state.webgazer.applyKalmanFilter(false); // kalman filter doesn't seem to work yet with threadedridge. - - // set state parameters - state.round_predictions = params.round_predictions; - state.sampling_interval = params.sampling_interval; - - // sets state for initialization - state.initialized = false; - state.activeTrial = false; - state.gazeUpdateCallbacks = []; - state.domObserver = new MutationObserver(mutationObserverCallback); - - // hide video by default - extension.hideVideo(); - - // hide predictions by default - extension.hidePredictions(); - - if (params.auto_initialize) { - // starts webgazer, and once it initializes we stop mouseCalibration and - // pause webgazer data. - state.webgazer.begin().then(function () { - state.initialized = true; - extension.stopMouseCalibration(); - extension.pause(); - resolve(); - }).catch(function (error) { - console.error(error); - reject(error); - }); - } else { - resolve(); - } - }) - } - - // required, will be called when the trial starts (before trial loads) - extension.on_start = function (params) { - state.currentTrialData = []; - state.currentTrialTargets = {}; - state.currentTrialSelectors = params.targets; - - state.domObserver.observe(jsPsych.getDisplayElement(), {childList: true}) - - } - - // required will be called when the trial loads - extension.on_load = function (params) { - - // set current trial start time - state.currentTrialStart = performance.now(); - - // resume data collection - // state.webgazer.resume(); - - extension.startSampleInterval(); - - // set internal flag - state.activeTrial = true; - } - - // required, will be called when jsPsych.finishTrial() is called - // must return data object to be merged into data. - extension.on_finish = function (params) { - - // pause the eye tracker - extension.stopSampleInterval(); - - // stop watching the DOM - state.domObserver.disconnect(); - - // state.webgazer.pause(); - - // set internal flag - state.activeTrial = false; - - // send back the gazeData - return { - webgazer_data: state.currentTrialData, - webgazer_targets: state.currentTrialTargets - } - } - - extension.start = function () { - if(typeof state.webgazer == 'undefined'){ - console.error('Failed to start webgazer. Things to check: Is webgazer.js loaded? Is the webgazer extension included in jsPsych.init?') - return; - } - return new Promise(function (resolve, reject) { - state.webgazer.begin().then(function () { - state.initialized = true; - extension.stopMouseCalibration(); - extension.pause(); - resolve(); - }).catch(function (error) { - console.error(error); - reject(error); - }); - }); - } - - extension.startSampleInterval = function(interval){ - interval = typeof interval == 'undefined' ? state.sampling_interval : interval; - state.gazeInterval = setInterval(function(){ - state.webgazer.getCurrentPrediction().then(handleGazeDataUpdate); - }, state.sampling_interval); - // repeat the call here so that we get one immediate execution. above will not - // start until state.sampling_interval is reached the first time. - state.webgazer.getCurrentPrediction().then(handleGazeDataUpdate); - } - - extension.stopSampleInterval = function(){ - clearInterval(state.gazeInterval); - } - - extension.isInitialized = function(){ - return state.initialized; - } - - extension.faceDetected = function () { - return state.webgazer.getTracker().predictionReady; - } - - extension.showPredictions = function () { - state.webgazer.showPredictionPoints(true); - } - - extension.hidePredictions = function () { - state.webgazer.showPredictionPoints(false); - } - - extension.showVideo = function () { - state.webgazer.showVideo(true); - state.webgazer.showFaceOverlay(true); - state.webgazer.showFaceFeedbackBox(true); - } - - extension.hideVideo = function () { - state.webgazer.showVideo(false); - state.webgazer.showFaceOverlay(false); - state.webgazer.showFaceFeedbackBox(false); - } - - extension.resume = function () { - state.webgazer.resume(); - } - - extension.pause = function () { - state.webgazer.pause(); - // sometimes gaze dot will show and freeze after pause? - if(document.querySelector('#webgazerGazeDot')){ - document.querySelector('#webgazerGazeDot').style.display = 'none'; - } - } - - extension.resetCalibration = function(){ - state.webgazer.clearData(); - } - - extension.stopMouseCalibration = function () { - state.webgazer.removeMouseEventListeners() - } - - extension.startMouseCalibration = function () { - state.webgazer.addMouseEventListeners() - } - - extension.calibratePoint = function (x, y) { - state.webgazer.recordScreenPosition(x, y, 'click'); - } - - extension.setRegressionType = function (regression_type) { - var valid_regression_models = ['ridge', 'weightedRidge', 'threadedRidge']; - if (valid_regression_models.includes(regression_type)) { - state.webgazer.setRegression(regression_type) - } else { - console.warn('Invalid regression_type parameter for webgazer.setRegressionType. Valid options are ridge, weightedRidge, and threadedRidge.') - } - } - - extension.getCurrentPrediction = function () { - return state.webgazer.getCurrentPrediction(); - } - - extension.onGazeUpdate = function(callback){ - state.gazeUpdateCallbacks.push(callback); - return function(){ - state.gazeUpdateCallbacks = state.gazeUpdateCallbacks.filter(function(item){ - return item !== callback; - }); - } - } - - function handleGazeDataUpdate(gazeData, elapsedTime) { - if (gazeData !== null){ - var d = { - x: state.round_predictions ? Math.round(gazeData.x) : gazeData.x, - y: state.round_predictions ? Math.round(gazeData.y) : gazeData.y, - t: gazeData.t - } - if(state.activeTrial) { - //console.log(`handleUpdate: t = ${Math.round(gazeData.t)}, now = ${Math.round(performance.now())}`); - d.t = Math.round(gazeData.t - state.currentTrialStart) - state.currentTrialData.push(d); // add data to current trial's data - } - state.currentGaze = d; - for(var i=0; i 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