diff --git a/README.md b/README.md index 89eae08..968c2f9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ This repository is intended to be cloned/forked in order to set up a working jsPsych experiment via GitHub pages. This makes it simple to start exploring jsPsych. -**This project currently uses jsPsych v3.1** +**This project currently uses jsPsych v6.3.0** Instructions ------------ diff --git a/css/jspsych.css b/css/jspsych.css index dad8776..9a07da4 100644 --- a/css/jspsych.css +++ b/css/jspsych.css @@ -1,122 +1,206 @@ -/* +/* * CSS for jsPsych experiments. * - * This stylesheet provides minimal styling to make jsPsych + * This stylesheet provides minimal styling to make jsPsych * experiments look polished without any additional styles. - * */ -/* - * - * fonts and type - * - */ - -@import url(https://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700); + @import url(https://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700); -html { - font-family: 'Open Sans', 'Arial', sans-serif; - font-size: 18px; - line-height: 1.6em; -} +/* Container holding jsPsych content */ -p { - clear:both; -} + .jspsych-display-element { + display: flex; + flex-direction: column; + overflow-y: auto; + } -.very-small { - font-size: 50%; -} + .jspsych-display-element:focus { + outline: none; + } -.small { - font-size: 75%; -} + .jspsych-content-wrapper { + display: flex; + margin: auto; + flex: 1 1 100%; + width: 100%; + } -.large { - font-size: 125%; -} + .jspsych-content { + max-width: 95%; /* this is mainly an IE 10-11 fix */ + text-align: center; + margin: auto; /* this is for overflowing content */ + } -.very-large { - font-size: 150%; -} + .jspsych-top { + align-items: flex-start; + } -/* - * - * Classes for changing location of things - * - */ - -.left { - float: left; -} + .jspsych-middle { + align-items: center; + } -.right { - float: right; -} +/* fonts and type */ -.center-content { - text-align: center; +.jspsych-display-element { + font-family: 'Open Sans', 'Arial', sans-serif; + font-size: 18px; + line-height: 1.6em; } -/* - * - * Form elements like input fields and buttons - * - */ +/* Form elements like input fields and buttons */ -input[type="text"] { - font-family: 'Open Sans', 'Arial', sans-sefif; - font-size: 14px; +.jspsych-display-element input[type="text"] { + font-family: 'Open Sans', 'Arial', sans-serif; + font-size: 14px; } -button { - padding: 0.5em; - background-color: #eaeaea; - border: 1px solid #eaeaea; - color: #333; - font-family: 'Open Sans', 'Arial', sans-serif; - font-size: 14px; - cursor: pointer; +/* 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; } -button:hover { - border:1px solid #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; + } } -/* - * - * Container holding jsPsych content - * - */ +.jspsych-btn:active { + background-color: #ddd; + border-color:#000000; +} +.jspsych-btn:disabled { + background-color: #eee; + color: #aaa; + border-color: #ccc; + cursor: not-allowed; +} -.jspsych-display-element { - width: 800px; - margin: 50px auto 50px auto; +/* 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; } -/* - * - * PLUGIN: jspsych-single-stim - * - */ - -#jspsych-single-stim-stimulus { - display: block; - margin-left: auto; - margin-right: auto; +/* 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%; } -/* - * - * PLUGIN: jspsych-survey-text - * - */ - - .jspsych-survey-text { - margin: 0.25em 0em; - } - - .jspsych-survey-text-question { - margin: 2em 0em; - } \ No newline at end of file +/* Control appearance of jsPsych.data.displayData() */ +#jspsych-data-display { + text-align: left; +} diff --git a/img/blue.png b/img/blue.png new file mode 100644 index 0000000..820bdce Binary files /dev/null and b/img/blue.png differ diff --git a/img/congruent_left.gif b/img/congruent_left.gif deleted file mode 100644 index 4edfbb5..0000000 Binary files a/img/congruent_left.gif and /dev/null differ diff --git a/img/congruent_right.gif b/img/congruent_right.gif deleted file mode 100644 index 0c4855e..0000000 Binary files a/img/congruent_right.gif and /dev/null differ diff --git a/img/incongruent_left.gif b/img/incongruent_left.gif deleted file mode 100644 index 922503c..0000000 Binary files a/img/incongruent_left.gif and /dev/null differ diff --git a/img/incongruent_right.gif b/img/incongruent_right.gif deleted file mode 100644 index 43b6113..0000000 Binary files a/img/incongruent_right.gif and /dev/null differ diff --git a/img/orange.png b/img/orange.png new file mode 100644 index 0000000..108e6e5 Binary files /dev/null and b/img/orange.png differ diff --git a/index.html b/index.html index b7eab9a..896a8c6 100644 --- a/index.html +++ b/index.html @@ -1,79 +1,120 @@ - + - - - My experiment - - - - - - - - - - - -
- - - + + + My experiment + + + + + + + + + + \ No newline at end of file diff --git a/scripts/jspsych.js b/scripts/jspsych.js index 9a531f4..4e886ec 100644 --- a/scripts/jspsych.js +++ b/scripts/jspsych.js @@ -1,946 +1,3015 @@ -/** - * jspsych.js - * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki - * - **/ -(function($) { - jsPsych = (function() { - - // - // public object - // - var core = {}; - - // - // private class variables - // - - // options - var opts = {}; - // exp structure - var exp_blocks = []; - // flow control - var curr_block = 0; - // everything loaded? - var initialized = false; - // target DOM element - var DOM_target; - // time that the experiment began - var exp_start_time; - - // - // public methods - // - - // core.init creates the experiment and starts running it - // display_element is an HTML element (usually a
) that will display jsPsych content - // options is an object: { - // "experiment_structure": an array of blocks specifying the experiment - // "finish": function to execute when the experiment ends - // } - // - core.init = function(options) { - - // reset the key variables - exp_blocks = []; - opts = {}; - initialized = false; - curr_block = 0; - - // check if there is a body element on the page - var default_display_element = $('body'); - if (default_display_element.length === 0) { - $(document.documentElement).append($('')); - default_display_element = $('body'); - } +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'); + - var defaults = { - 'display_element': default_display_element, - 'on_finish': function(data) { - return undefined; - }, - 'on_trial_start': function() { - return undefined; - }, - 'on_trial_finish': function() { - return undefined; - }, - 'on_data_update': function(data) { - return undefined; + // 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); + }) + } + } + } - // import options - opts = $.extend({}, defaults, options); + }; + + // execute init() when the document is ready + if (document.readyState === "complete") { + init(); + } else { + window.addEventListener("load", init); + } + } - // set target - DOM_target = opts.display_element; + core.progress = function() { - // add CSS class to DOM_target - DOM_target.addClass('jspsych-display-element'); + var percent_complete = typeof timeline == 'undefined' ? 0 : timeline.percentComplete(); - run(); - }; + var obj = { + "total_trials": typeof timeline == 'undefined' ? undefined : timeline.length(), + "current_trial_global": global_trial_index, + "percent_complete": percent_complete + }; - // core.data returns all of the data objects for each block as an array - // where core.data[0] = data object from block 0, etc... - // if flatten is true, then the hierarchical structure of the data - // is removed and each array entry will be a single trial. + return obj; + }; - core.data = function() { - var all_data = []; - - for (var i = 0; i < exp_blocks.length; i++) { - all_data[i] = exp_blocks[i].data; - } + core.startTime = function() { + return exp_start_time; + }; - return all_data; - }; + 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.progress returns an object with the following properties - // total_blocks: the number of total blocks in the experiment - // total_trials: the number of total trials in the experiment - // current_trial_global: the current trial number in global terms - // i.e. if each block has 20 trials and the experiment is - // currently in block 2 trial 10, this has a value of 30. - // current_trial_local: the current trial number within the block. - // current_block: the current block number. + core.getDisplayContainerElement = function(){ + return DOM_container; + } - core.progress = function() { + core.finishTrial = function(data) { - var total_trials = 0; - for (var i = 0; i < exp_blocks.length; i++) { - total_trials += exp_blocks[i].num_trials; - } + if(current_trial_finished){ return; } + current_trial_finished = true; - var current_trial_global = 0; - for (var i = 0; i < curr_block; i++) { - current_trial_global += exp_blocks[i].num_trials; - } - current_trial_global += exp_blocks[curr_block].trial_idx; + // 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); + } - var obj = { - "total_blocks": exp_blocks.length, - "total_trials": total_trials, - "current_trial_global": current_trial_global, - "current_trial_local": exp_blocks[curr_block].trial_idx, - "current_block": curr_block - }; + // 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.startTime = function() { - return exp_start_time; - }; + core.allTimelineVariables = function(){ + return timeline.allTimelineVariables(); + } - // core.totalTime() returns the length of time in ms since the experiment began + core.addNodeToEndOfTimeline = function(new_timeline, preload_callback){ + timeline.insert(new_timeline); + } - core.totalTime = function() { - return (new Date()).getTime() - exp_start_time.getTime(); - }; + core.pauseExperiment = function(){ + paused = true; + } - // core.preloadImage will load images into the browser cache so that they appear quickly when - // used during a trial. - // images: array of paths to images - // callback_complete: a function with no arguments that calls when loading is complete - // callback_load: a function with a single argument that calls whenever an image is loaded - // argument is the number of images currently loaded. + core.resumeExperiment = function(){ + paused = false; + if(waiting){ + waiting = false; + nextTrial(); + } + } - core.preloadImages = function(images, callback_complete, callback_load) { + core.loadFail = function(message){ + message = message || '

The experiment failed to load.

'; + loadfail = true; + DOM_target.innerHTML = message; + } - // flatten the images array - images = flatten(images); + core.getSafeModeStatus = function() { + return file_protocol; + } - var n_loaded = 0; - var loadfn = (typeof callback_load === 'undefined') ? function() {} : callback_load; - var finishfn = (typeof callback_complete === 'undefined') ? function() {} : callback_complete; + function TimelineNode(parameters, parent, relativeID) { - for (var i = 0; i < images.length; i++) { - var img = new Image(); + // a unique ID for this node, relative to the parent + var relative_id; - img.onload = function() { - n_loaded++; - loadfn(n_loaded); - if (n_loaded == images.length) { - finishfn(); - } - }; + // store the parent for this node + var parent_node; - img.src = images[i]; - } - }; - - core.getDisplayElement = function() { - return DOM_target; + // 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(); + } + } - // - // private functions // - // - function run() { - // take the experiment structure and dynamically create a set of blocks - exp_blocks = new Array(opts.experiment_structure.length); + 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(); + } + } - // iterate through block list to create trials - for (var i = 0; i < exp_blocks.length; i++) { + // 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; } + } - // record the start time - exp_start_time = new Date(); + // 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(); + } + - // begin! - run the first block - exp_blocks[0].next(); } + // 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++; + } - function nextBlock() { - curr_block += 1; - if (curr_block == exp_blocks.length) { - finishExperiment(); - } - else { - exp_blocks[curr_block].next(); - } } - function createBlock(trial_list) { - var block = { - trial_idx: -1, + // 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(); + } - trials: trial_list, - data: [], + // 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(); + } - next: function() { + // 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; + } + } - // call on_trial_finish() - // if not very first trial - // and not the last call in this block (no trial due to advance in block) - if (typeof this.trials[this.trial_idx + 1] != "undefined" && (curr_block != 0 || this.trial_idx > -1)) { - opts.on_trial_finish(); - }; - this.trial_idx = this.trial_idx + 1; + } - var curr_trial = this.trials[this.trial_idx]; + // no more loops on this timeline, we're done! + progress.done = true; + return true; + } + } - if (typeof curr_trial == "undefined") { - return this.done(); - } + // check the status of the done flag + this.isComplete = function() { + return progress.done; + } - // call on_trial_start() - opts.on_trial_start(); + // 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; + } - do_trial(this, curr_trial); - }, + // 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; + } + } - writeData: function(data_object) { - this.data[this.trial_idx] = data_object; - opts.on_data_update(data_object); - }, + // 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', { - html: data_string - })); - } - - // private function to save text file on local drive - function saveTextToFile(textstr, filename) { - var blobToSave = new Blob([textstr], { - type: 'text/plain' - }); - var blobURL = ""; - if (typeof window.webkitURL !== 'undefined') { - blobURL = window.webkitURL.createObjectURL(blobToSave); - } - else { - blobURL = window.URL.createObjectURL(blobToSave); - } - - var display_element = jsPsych.getDisplayElement(); - - display_element.append($('', { - id: 'jspsych-download-as-text-link', - href: blobURL, - css: { - display: 'none' - }, - download: filename, - html: 'download file' - })); - $('#jspsych-download-as-text-link')[0].click(); - } - - // - // A few helper functions to handle data format conversion - // - function flattenData(data_object, append_data) { - - append_data = (typeof append_data === undefined) ? {} : append_data; - - var trials = []; - - // loop through data_object - for (var i = 0; i < data_object.length; i++) { - for (var j = 0; j < data_object[i].length; j++) { - var data = $.extend({}, data_object[i][j], append_data); - trials.push(data); - } - } - return trials; - } - - // this function based on code suggested by StackOverflow users: - // http://stackoverflow.com/users/64741/zachary - // http://stackoverflow.com/users/317/joseph-sturtevant - function JSON2CSV(objArray) { - var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray; - var line = ''; - var result = ''; - var columns = []; - - var i = 0; - for (var j = 0; j < array.length; j++) { - for (var key in array[j]) { - var keyString = key + ""; - keyString = '"' + keyString.replace(/"/g, '""') + '",'; - if ($.inArray(key, columns) == -1) { - columns[i] = key; - line += keyString; - i++; - } - } - } + self.setTimelineVariablesOrder(); + + // extract all of the node level data and parameters + // but remove all of the timeline-level specific information + // since this will be used to copy things down hierarchically + var node_data = Object.assign({}, parameters); + delete node_data.timeline; + delete node_data.conditional_function; + delete node_data.loop_function; + delete node_data.randomize_order; + delete node_data.repetitions; + delete node_data.timeline_variables; + delete node_data.sample; + delete node_data.on_timeline_start; + delete node_data.on_timeline_finish; + node_trial_data = node_data; // store for later... + + // create a TimelineNode for each element in the timeline + for (var i = 0; i < parameters.timeline.length; i++) { + // merge parameters + var merged_parameters = Object.assign({}, node_data, parameters.timeline[i]); + // merge any data from the parent node into child nodes + if(typeof node_data.data == 'object' && typeof parameters.timeline[i].data == 'object'){ + var merged_data = Object.assign({}, node_data.data, parameters.timeline[i].data); + merged_parameters.data = merged_data; + } + timeline_parameters.timeline.push(new TimelineNode(merged_parameters, self, i)); + } + } + // if there is no timeline parameter, then this node is a trial node + else { + // check to see if a valid trial type is defined + var trial_type = parameters.type; + if (typeof trial_type == 'undefined') { + console.error('Trial level node is missing the "type" parameter. The parameters for the node are: ' + JSON.stringify(parameters)); + } else if ((typeof jsPsych.plugins[trial_type] == 'undefined') && (trial_type.toString().replace(/\s/g,'') != "function(){returntimeline.timelineVariable(varname);}")) { + console.error('No plugin loaded for trials of type "' + trial_type + '"'); + } + // create a deep copy of the parameters for the trial + trial_parameters = Object.assign({}, parameters); + } - line = line.slice(0, - 1); - result += line + '\r\n'; + }(); + } - for (var i = 0; i < array.length; i++) { - var line = ''; - for (var j = 0; j < columns.length; j++) { - var value = (typeof array[i][columns[j]] === 'undefined') ? '' : array[i][columns[j]]; - var valueString = value + ""; - line += '"' + valueString.replace(/"/g, '""') + '",'; - } + function startExperiment() { - line = line.slice(0, - 1); - result += line + '\r\n'; - } + loaded = true; - return result; - } - - return module; - - })(); - - jsPsych.turk = (function() { - - // turk info - var turk_info; - - var module = {}; - - // core.turkInfo gets information relevant to mechanical turk experiments. returns an object - // containing the workerID, assignmentID, and hitID, and whether or not the HIT is in - // preview mode, meaning that they haven't accepted the HIT yet. - module.turkInfo = function(force_refresh) { - // default value is false - force_refresh = (typeof force_refresh === 'undefined') ? false : force_refresh; - // if we already have the turk_info and force_refresh is false - // then just return the cached version. - if (typeof turk_info !== 'undefined' && !force_refresh) { - return turk_info; - } else { + // show progress bar if requested + if (opts.show_progress_bar === true) { + drawProgressBar(opts.message_progress_bar); + } - var turk = {}; + // record the start time + exp_start_time = new Date(); - var param = function(url, name) { - name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]"); - var regexS = "[\\?&]" + name + "=([^&#]*)"; - var regex = new RegExp(regexS); - var results = regex.exec(url); - return (results == null) ? "" : results[1]; - }; + // begin! + timeline.advance(); + doTrial(timeline.trial()); - var src = param(window.location.href, "assignmentId") ? window.location.href : document.referrer; + } - var keys = ["assignmentId", "hitId", "workerId", "turkSubmitTo"]; - keys.map( + function finishExperiment() { - function(key) { - turk[key] = unescape(param(src, key)); - }); + if(typeof timeline.end_message !== 'undefined'){ + DOM_target.innerHTML = timeline.end_message; + } - turk.previewMode = (turk.assignmentId == "ASSIGNMENT_ID_NOT_AVAILABLE"); + opts.on_finish(jsPsych.data.get()); - turk.outsideTurk = (!turk.previewMode && turk.hitId === "" && turk.assignmentId == "" && turk.workerId == "") + } - turk_info = turk; + function nextTrial() { + // if experiment is paused, don't do anything. + if(paused) { + waiting = true; + return; + } - return turk; - } + global_trial_index++; - }; + // advance timeline + timeline.markCurrentTrialComplete(); + var complete = timeline.advance(); - // core.submitToTurk will submit a MechanicalTurk ExternalHIT type + // update progress bar if shown + if (opts.show_progress_bar === true && opts.auto_update_progress_bar == true) { + updateProgressBar(); + } - module.submitToTurk = function(data) { + // check if experiment is over + if (complete) { + finishExperiment(); + return; + } - var turkInfo = core.turkInfo(); - var assignmentId = turkInfo.assignmentId; - var turkSubmitTo = turkInfo.turkSubmitTo; + doTrial(timeline.trial()); + } - if (!assignmentId || !turkSubmitTo) return; + function doTrial(trial) { - var dataString = []; + current_trial = trial; + current_trial_finished = false; - for (var key in data) { + // process all timeline variables for this trial + evaluateTimelineVariables(trial); - if (data.hasOwnProperty(key)) { - dataString.push(key + "=" + escape(data[key])); - } - } + // evaluate variables that are functions + evaluateFunctionParameters(trial); - dataString.push("assignmentId=" + assignmentId); + // get default values for parameters + setDefaultValues(trial); - var url = turkSubmitTo + "/mturk/externalSubmit?" + dataString.join("&"); + // about to execute callbacks + jsPsych.internal.call_immediate = true; - window.location.href = url; - } - - return module; - - })(); + // call experiment wide callback + opts.on_trial_start(trial); + + // call trial specific callback if it exists + if(typeof trial.on_start == 'function'){ + trial.on_start(trial); + } + + // call any on_start functions for extensions + if(Array.isArray(trial.extensions)){ + 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 + } + } - var start_time; - if (rt_method == 'date') { - start_time = (new Date()).getTime(); - } - if (rt_method == 'performance') { - start_time = performance.now(); - } + // 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; + } + } - var listener_id; - - var listener_function = function(e) { + // 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' + } + } - var key_time; - if (rt_method == 'date') { - key_time = (new Date()).getTime(); - } - if (rt_method == 'performance') { - key_time = performance.now(); - } + return module; +})(); - var valid_response = false; - if (typeof valid_responses === 'undefined' || valid_responses.length === 0) { - valid_response = true; - } - for (var i = 0; i < valid_responses.length; i++) { - if (typeof valid_responses[i] == 'string') { - if(typeof keylookup[valid_responses[i]] !== 'undefined'){ - if(e.which == keylookup[valid_responses[i]]) { - valid_response = true; - } - } else { - throw new Error('Invalid key string specified for getKeyboardResponse'); - } - } else if (e.which == valid_responses[i]) { - valid_response = true; - } - } +jsPsych.extensions = (function(){ + return {}; +})(); - if (valid_response) { - - var after_up = function(up) { - - if(up.which == e.which) { - $(document).off('keyup', after_up); - - if($.inArray(listener_id, keyboard_listeners) > -1) { - - if(!persist){ - // remove keyboard listener - module.cancelKeyboardResponse(listener_id); - } - - callback_function({ - key: e.which, - rt: key_time - start_time - }); - } - } - }; - - $(document).keyup(after_up); - } - }; - - $(document).keydown(listener_function); - - // create listener id object - listener_id = {type: 'keydown', fn: listener_function}; - - // add this keyboard listener to the list of listeners - keyboard_listeners.push(listener_id); - - return listener_id; - - }; - - module.cancelKeyboardResponse = function(listener) { - // remove the listener from the doc - $(document).off(listener.type, listener.fn); - - // remove the listener from the list of listeners - if($.inArray(listener, keyboard_listeners) > -1) { - keyboard_listeners.splice($.inArray(listener, keyboard_listeners), 1); - } - }; - - module.cancelAllKeyboardResponses = function() { - for(var i = 0; i< keyboard_listeners.length; i++){ - $(document).off(keyboard_listeners[i].type, keyboard_listeners[i].fn); - } - keyboard_listeners = []; - }; - - // keycode lookup associative array - var keylookup = { - 'backspace': 8, - 'tab': 9, - 'enter': 13, - 'shift': 16, - 'ctrl': 17, - 'alt': 18, - 'pause': 19, - 'capslock': 20, - 'esc': 27, - 'space':32, - 'spacebar':32, - ' ':32, - 'pageup': 33, - 'pagedown': 34, - 'end': 35, - 'home': 36, - 'leftarrow': 37, - 'uparrow': 38, - 'rightarrow': 39, - 'downarrow': 40, - 'insert': 45, - 'delete': 46, - '0': 48, - '1': 49, - '2': 50, - '3': 51, - '4': 52, - '5': 53, - '6': 54, - '7': 55, - '8': 56, - '9': 57, - 'a': 65, - 'b': 66, - 'c': 67, - 'd': 68, - 'e': 69, - 'f': 70, - 'g': 71, - 'h': 72, - 'i': 73, - 'j': 74, - 'k': 75, - 'l': 76, - 'm': 77, - 'n': 78, - 'o': 79, - 'p': 80, - 'q': 81, - 'r': 82, - 's': 83, - 't': 84, - 'u': 85, - 'v': 86, - 'w': 87, - 'x': 88, - 'y': 89, - 'z': 90, - 'A': 65, - 'B': 66, - 'C': 67, - 'D': 68, - 'E': 69, - 'F': 70, - 'G': 71, - 'H': 72, - 'I': 73, - 'J': 74, - 'K': 75, - 'L': 76, - 'M': 77, - 'N': 78, - 'O': 79, - 'P': 80, - 'Q': 81, - 'R': 82, - 'S': 83, - 'T': 84, - 'U': 85, - 'V': 86, - 'W': 87, - 'X': 88, - 'Y': 89, - 'Z': 90, - '0numpad': 96, - '1numpad': 97, - '2numpad': 98, - '3numpad': 99, - '4numpad': 100, - '5numpad': 101, - '6numpad': 102, - '7numpad': 103, - '8numpad': 104, - '9numpad': 105, - 'multiply': 106, - 'plus': 107, - 'minus': 109, - 'decimal': 110, - 'divide': 111, - 'F1': 112, - 'F2': 113, - 'F3': 114, - 'F4': 115, - 'F5': 116, - 'F6': 117, - 'F7': 118, - 'F8': 119, - 'F9': 120, - 'F10': 121, - 'F11': 122, - 'F12': 123, - '=': 187, - ',': 188, - '.': 190, - '/': 191, - '`': 192, - '[': 219, - '\\': 220, - ']': 221 - }; - - // - // These are public functions, intended to be used for developing plugins. - // They aren't considered part of the normal API for the core library. - // +jsPsych.data = (function() { - module.normalizeTrialVariables = function(trial, protect) { + var module = {}; - protect = (typeof protect === 'undefined') ? [] : protect; + // data storage object + var allData = DataCollection(); - var keys = getKeys(trial); + // browser interaction event data + var interactionData = DataCollection(); - var tmp = {}; - for (var i = 0; i < keys.length; i++) { + // data properties for all trials + var dataProperties = {}; - var process = true; - for (var j = 0; j < protect.length; j++) { - if (protect[j] == keys[i]) { - process = false; - break; - } - } + // cache the query_string + var query_string; - if (typeof trial[keys[i]] == "function" && process) { - tmp[keys[i]] = trial[keys[i]].call(); - } - else { - tmp[keys[i]] = trial[keys[i]]; - } + // DataCollection + function DataCollection(data){ - } + var data_collection = {}; - return tmp; + var trials = typeof data === 'undefined' ? [] : data; - }; + data_collection.push = function(new_data){ + trials.push(new_data); + return data_collection; + } - // if possible_array is not an array, then return a one-element array - // containing possible_array - module.enforceArray = function(params, possible_arrays) { + data_collection.join = function(other_data_collection){ + trials = trials.concat(other_data_collection.values()); + return data_collection; + } - // function to check if something is an array, fallback - // to string method if browser doesn't support Array.isArray - var ckArray = Array.isArray || function(a) { - return toString.call(a) == '[object Array]'; - }; + data_collection.top = function(){ + if(trials.length <= 1){ + return data_collection; + } else { + return DataCollection([trials[trials.length-1]]); + } + } - for (var i = 0; i < possible_arrays.length; i++) { - if(typeof params[possible_arrays[i]] !== 'undefined'){ - params[possible_arrays[i]] = ckArray(params[possible_arrays[i]]) ? params[possible_arrays[i]] : [params[possible_arrays[i]]]; - } - } + /** + * 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)); + } - return params; - }; - - function getKeys(obj) { - var r = []; - for (var k in obj) { - if (!obj.hasOwnProperty(k)) continue; - r.push(k); - } - return r; + /** + * 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 module; - })(); - - // methods used in multiple modules - - // private function to flatten nested arrays - function flatten(arr, out) { - out = (typeof out === 'undefined') ? [] : out; - for (var i = 0; i < arr.length; i++) { - if (Array.isArray(arr[i])) { - flatten(arr[i], out); - } - else { - out.push(arr[i]); + } + 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
'); + document.getElementById('jspsych-download-as-text-link').click(); + } + + // + // A few helper functions to handle data format conversion + // + + // this function based on code suggested by StackOverflow users: + // http://stackoverflow.com/users/64741/zachary + // http://stackoverflow.com/users/317/joseph-sturtevant + + function JSON2CSV(objArray) { + var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray; + var line = ''; + var result = ''; + var columns = []; + + var i = 0; + for (var j = 0; j < array.length; j++) { + for (var key in array[j]) { + var keyString = key + ""; + keyString = '"' + keyString.replace(/"/g, '""') + '",'; + if (!columns.includes(key)) { + columns[i] = key; + line += keyString; + i++; + } + } + } + + line = line.slice(0, -1); + result += line + '\r\n'; + + for (var i = 0; i < array.length; i++) { + var line = ''; + for (var j = 0; j < columns.length; j++) { + var value = (typeof array[i][columns[j]] === 'undefined') ? '' : array[i][columns[j]]; + if(typeof value == 'object') { + value = JSON.stringify(value); + } + var valueString = value + ""; + line += '"' + valueString.replace(/"/g, '""') + '",'; + } + + line = line.slice(0, -1); + result += line + '\r\n'; + } + + return result; + } + + // this function is modified from StackOverflow: + // http://stackoverflow.com/posts/3855394 + + function getQueryString() { + var a = window.location.search.substr(1).split('&'); + if (a == "") return {}; + var b = {}; + for (var i = 0; i < a.length; ++i) + { + var p=a[i].split('=', 2); + if (p.length == 1) + b[p[0]] = ""; + else + b[p[0]] = decodeURIComponent(p[1].replace(/\+/g, " ")); + } + return b; + } + + return module; + +})(); + +jsPsych.turk = (function() { + + var module = {}; + + // core.turkInfo gets information relevant to mechanical turk experiments. returns an object + // containing the workerID, assignmentID, and hitID, and whether or not the HIT is in + // preview mode, meaning that they haven't accepted the HIT yet. + module.turkInfo = function() { + + var turk = {}; + + var param = function(url, name) { + name = name.replace(/[\[]/, "\\\[").replace(/[\]]/, "\\\]"); + var regexS = "[\\?&]" + name + "=([^&#]*)"; + var regex = new RegExp(regexS); + var results = regex.exec(url); + return (results == null) ? "" : results[1]; + }; + + var src = param(window.location.href, "assignmentId") ? window.location.href : document.referrer; + + var keys = ["assignmentId", "hitId", "workerId", "turkSubmitTo"]; + keys.map( + + function(key) { + turk[key] = unescape(param(src, key)); + }); + + turk.previewMode = (turk.assignmentId == "ASSIGNMENT_ID_NOT_AVAILABLE"); + + turk.outsideTurk = (!turk.previewMode && turk.hitId === "" && turk.assignmentId == "" && turk.workerId == "") + + turk_info = turk; + + return turk; + + }; + + // core.submitToTurk will submit a MechanicalTurk ExternalHIT type + module.submitToTurk = function(data) { + + var turkInfo = jsPsych.turk.turkInfo(); + var assignmentId = turkInfo.assignmentId; + var turkSubmitTo = turkInfo.turkSubmitTo; + + if (!assignmentId || !turkSubmitTo) return; + + var dataString = []; + + for (var key in data) { + + if (data.hasOwnProperty(key)) { + dataString.push(key + "=" + escape(data[key])); + } + } + + dataString.push("assignmentId=" + assignmentId); + + var url = turkSubmitTo + "/mturk/externalSubmit?" + dataString.join("&"); + + window.location.href = url; + }; + + return module; + +})(); + +jsPsych.randomization = (function() { + + var module = {}; + + module.repeat = function(array, repetitions, unpack) { + + var arr_isArray = Array.isArray(array); + var rep_isArray = Array.isArray(repetitions); + + // if array is not an array, then we just repeat the item + if (!arr_isArray) { + if (!rep_isArray) { + array = [array]; + repetitions = [repetitions]; + } else { + repetitions = [repetitions[0]]; + console.log('Unclear parameters given to randomization.repeat. Multiple set sizes specified, but only one item exists to sample. Proceeding using the first set size.'); + } + } else { + if (!rep_isArray) { + var reps = []; + for (var i = 0; i < array.length; i++) { + reps.push(repetitions); + } + repetitions = reps; + } else { + if (array.length != repetitions.length) { + console.warning('Unclear parameters given to randomization.repeat. Items and repetitions are unequal lengths. Behavior may not be as expected.'); + // throw warning if repetitions is too short, use first rep ONLY. + if (repetitions.length < array.length) { + var reps = []; + for (var i = 0; i < array.length; i++) { + reps.push(repetitions); + } + repetitions = reps; + } else { + // throw warning if too long, and then use the first N + repetitions = repetitions.slice(0, array.length); + } + } + } + } + + // should be clear at this point to assume that array and repetitions are arrays with == length + var allsamples = []; + for (var i = 0; i < array.length; i++) { + for (var j = 0; j < repetitions[i]; j++) { + if(array[i] == null || typeof array[i] != 'object'){ + allsamples.push(array[i]); + } else { + allsamples.push(Object.assign({}, array[i])); + } + + } + } + + var out = shuffle(allsamples); + + if (unpack) { + out = unpackArray(out); + } + + return out; + } + + module.shuffle = function(arr) { + if(!Array.isArray(arr)){ + console.error('Argument to jsPsych.randomization.shuffle() must be an array.') + } + return shuffle(arr); + } + + module.shuffleNoRepeats = function(arr, equalityTest) { + if(!Array.isArray(arr)){ + console.error('First argument to jsPsych.randomization.shuffleNoRepeats() must be an array.') + } + if(typeof equalityTest !== 'undefined' && typeof equalityTest !== 'function'){ + console.error('Second argument to jsPsych.randomization.shuffleNoRepeats() must be a function.') + } + // define a default equalityTest + if (typeof equalityTest == 'undefined') { + equalityTest = function(a, b) { + if (a === b) { + return true; + } else { + return false; + } + } + } + + var random_shuffle = shuffle(arr); + for (var i = 0; i < random_shuffle.length - 1; i++) { + if (equalityTest(random_shuffle[i], random_shuffle[i + 1])) { + // neighbors are equal, pick a new random neighbor to swap (not the first or last element, to avoid edge cases) + var random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1; + // test to make sure the new neighbor isn't equal to the old one + while ( + equalityTest(random_shuffle[i + 1], random_shuffle[random_pick]) || + (equalityTest(random_shuffle[i + 1], random_shuffle[random_pick + 1]) || equalityTest(random_shuffle[i + 1], random_shuffle[random_pick - 1])) + ) { + random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1; + } + var new_neighbor = random_shuffle[random_pick]; + random_shuffle[random_pick] = random_shuffle[i + 1]; + random_shuffle[i + 1] = new_neighbor; + } + } + + return random_shuffle; + } + + module.shuffleAlternateGroups = function(arr_groups, random_group_order){ + if(typeof random_group_order == 'undefined'){ + random_group_order = false; + } + + var n_groups = arr_groups.length; + if(n_groups == 1){ + console.warn('jsPsych.randomization.shuffleAlternateGroups was called with only one group. Defaulting to simple shuffle.'); + return(module.shuffle(arr_groups[0])); + } + + var group_order = []; + for(var i=0; i arr.length) { + console.error("Cannot take a sample " + + "larger than the size of the set of items to sample."); + } + return jsPsych.randomization.shuffle(arr).slice(0,size); + } + + module.sampleWithReplacement = function(arr, size, weights) { + if(!Array.isArray(arr)){ + console.error("First argument to jsPsych.randomization.sampleWithReplacement() must be an array") + } + + var normalized_weights = []; + if(typeof weights !== 'undefined'){ + if(weights.length !== arr.length){ + console.error('The length of the weights array must equal the length of the array '+ + 'to be sampled from.'); + } + var weight_sum = 0; + for(var i=0; i cumulative_weights[index]) { index++; } + samp.push(arr[index]); + } + return samp; + } + + module.factorial = function(factors, repetitions, unpack) { + + var factorNames = Object.keys(factors); + + var factor_combinations = []; + + for (var i = 0; i < factors[factorNames[0]].length; i++) { + factor_combinations.push({}); + factor_combinations[i][factorNames[0]] = factors[factorNames[0]][i]; + } + + for (var i = 1; i < factorNames.length; i++) { + var toAdd = factors[factorNames[i]]; + var n = factor_combinations.length; + for (var j = 0; j < n; j++) { + var base = factor_combinations[j]; + for (var k = 0; k < toAdd.length; k++) { + var newpiece = {}; + newpiece[factorNames[i]] = toAdd[k]; + factor_combinations.push(Object.assign({}, base, newpiece)); + } + } + factor_combinations.splice(0, n); + } + + repetitions = (typeof repetitions === 'undefined') ? 1 : repetitions; + var with_repetitions = module.repeat(factor_combinations, repetitions, unpack); + + return with_repetitions; + } + + module.randomID = function(length){ + var result = ''; + var length = (typeof length == 'undefined') ? 32 : length; + var chars = '0123456789abcdefghjklmnopqrstuvwxyz'; + for(var i = 0; i= 0) { + k = n; + } else { + k = len + n; + if (k < 0) {k = 0;} + } + var currentElement; + while (k < len) { + currentElement = O[k]; + if (searchElement === currentElement || + (searchElement !== searchElement && currentElement !== currentElement)) { // NaN !== NaN + return true; + } + k++; + } + return false; + }; +} + +// polyfill for Array.isArray +if (!Array.isArray) { + Array.isArray = function(arg) { + return Object.prototype.toString.call(arg) === '[object Array]'; + }; +} diff --git a/scripts/plugins/jspsych-animation.js b/scripts/plugins/jspsych-animation.js index d7520cb..ce56c9d 100644 --- a/scripts/plugins/jspsych-animation.js +++ b/scripts/plugins/jspsych-animation.js @@ -1,139 +1,189 @@ /** * jsPsych plugin for showing animations and recording keyboard responses * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-animation + * + * documentation: docs.jspsych.org */ -(function($) { - jsPsych.animation = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['choices', 'data']); - - var trials = new Array(params.stimuli.length); - for (var i = 0; i < trials.length; i++) { - trials[i] = {}; - trials[i].type = "animation"; - trials[i].stims = params.stimuli[i]; - trials[i].frame_time = params.frame_time || 250; - trials[i].frame_isi = params.frame_isi || 0; - trials[i].repetitions = params.repetitions || 1; - trials[i].choices = params.choices || []; - trials[i].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial; - trials[i].prompt = (typeof params.prompt === 'undefined') ? "" : params.prompt; - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data[i]; - } - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - var interval_time = trial.frame_time + trial.frame_isi; - var animate_frame = -1; - var reps = 0; - var startTime = (new Date()).getTime(); - var animation_sequence = []; - var responses = []; - var current_stim = ""; - - var animate_interval = setInterval(function() { - var showImage = true; - display_element.html(""); // clear everything - animate_frame++; - if (animate_frame == trial.stims.length) { - animate_frame = 0; - reps++; - if (reps >= trial.repetitions) { - endTrial(); - clearInterval(animate_interval); - showImage = false; - } - } - if (showImage) { - show_next_frame(); - } - }, interval_time); - - function show_next_frame() { - // show image - display_element.append($('', { - "src": trial.stims[animate_frame], - "id": 'jspsych-animation-image' - })); - - current_stim = trial.stims[animate_frame]; - - // record when image was shown - animation_sequence.push({ - "stimulus": current_stim, - "time": (new Date()).getTime() - startTime - }); - - if (trial.prompt !== "") { - display_element.append(trial.prompt); - } - - if (trial.frame_isi > 0) { - setTimeout(function() { - $('#jspsych-animation-image').css('visibility', 'hidden'); - current_stim = 'blank'; - // record when blank image was shown - animation_sequence.push({ - "stimulus": 'blank', - "time": (new Date()).getTime() - startTime - }); - }, trial.frame_time); - } - } - - var after_response = function(info) { - - responses.push({ - key_press: info.key, - rt: info.rt, - stimulus: current_stim - }); - - // after a valid response, the stimulus will have the CSS class 'responded' - // which can be used to provide visual feedback that a response was recorded - $("#jspsych-animation-image").addClass('responded'); - } - - // hold the jspsych response listener object in memory - // so that we can turn off the response collection when - // the trial ends - var response_listener = jsPsych.pluginAPI.getKeyboardResponse(after_response, trial.choices, 'date', true); - - function endTrial() { - - jsPsych.pluginAPI.cancelKeyboardResponse(response_listener); - - block.writeData($.extend({}, { - "trial_type": "animation", - "trial_index": block.trial_idx, - "animation_sequence": JSON.stringify(animation_sequence), - "responses": JSON.stringify(responses) - }, trial.data)); - - if(trial.timing_post_trial > 0){ - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } else { - block.next(); - } - } - }; - - return plugin; - })(); -})(jQuery); +jsPsych.plugins.animation = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('animation', 'stimuli', 'image'); + + plugin.info = { + name: 'animation', + description: '', + parameters: { + stimuli: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Stimuli', + default: undefined, + array: true, + description: 'The images to be displayed.' + }, + frame_time: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Frame time', + default: 250, + description: 'Duration to display each image.' + }, + frame_isi: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Frame gap', + default: 0, + description: 'Length of gap to be shown between each image.' + }, + sequence_reps: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Sequence repetitions', + default: 1, + description: 'Number of times to show entire sequence.' + }, + choices: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Choices', + default: jsPsych.ALL_KEYS, + array: true, + description: 'Keys subject uses to respond to stimuli.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below stimulus.' + }, + render_on_canvas: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Render on canvas', + default: true, + description: 'If true, the images will be drawn onto a canvas element (prevents blank screen between consecutive images in some browsers).'+ + 'If false, the image will be shown via an img element.' + } + } + } + + plugin.trial = function(display_element, trial) { + + var interval_time = trial.frame_time + trial.frame_isi; + var animate_frame = -1; + var reps = 0; + var startTime = performance.now(); + var animation_sequence = []; + var responses = []; + var current_stim = ""; + + if (trial.render_on_canvas) { + // first clear the display element (because the render_on_canvas method appends to display_element instead of overwriting it with .innerHTML) + if (display_element.hasChildNodes()) { + // can't loop through child list because the list will be modified by .removeChild() + while (display_element.firstChild) { + display_element.removeChild(display_element.firstChild); + } + } + var canvas = document.createElement("canvas"); + canvas.id = "jspsych-animation-image"; + canvas.style.margin = 0; + canvas.style.padding = 0; + display_element.insertBefore(canvas, null); + var ctx = canvas.getContext("2d"); + } + + var animate_interval = setInterval(function() { + var showImage = true; + if (!trial.render_on_canvas) { + display_element.innerHTML = ''; // clear everything + } + animate_frame++; + if (animate_frame == trial.stimuli.length) { + animate_frame = 0; + reps++; + if (reps >= trial.sequence_reps) { + endTrial(); + clearInterval(animate_interval); + showImage = false; + } + } + if (showImage) { + show_next_frame(); + } + }, interval_time); + + function show_next_frame() { + if (trial.render_on_canvas) { + display_element.querySelector('#jspsych-animation-image').style.visibility = 'visible'; + var img = new Image(); + img.src = trial.stimuli[animate_frame]; + canvas.height = img.naturalHeight; + canvas.width = img.naturalWidth; + ctx.drawImage(img,0,0); + if (trial.prompt !== null & animate_frame == 0 & reps == 0) { + display_element.insertAdjacentHTML('beforeend', trial.prompt); + } + } else { + // show image + display_element.innerHTML = ''; + if (trial.prompt !== null) { + display_element.innerHTML += trial.prompt; + } + } + current_stim = trial.stimuli[animate_frame]; + + // record when image was shown + animation_sequence.push({ + "stimulus": trial.stimuli[animate_frame], + "time": performance.now() - startTime + }); + + if (trial.frame_isi > 0) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('#jspsych-animation-image').style.visibility = 'hidden'; + current_stim = 'blank'; + // record when blank image was shown + animation_sequence.push({ + "stimulus": 'blank', + "time": performance.now() - startTime + }); + }, trial.frame_time); + } + } + + var after_response = function(info) { + + responses.push({ + key_press: info.key, + rt: info.rt, + stimulus: current_stim + }); + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-animation-image').className += ' responded'; + } + + // hold the jspsych response listener object in memory + // so that we can turn off the response collection when + // the trial ends + var response_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: true, + allow_held_key: false + }); + + function endTrial() { + + jsPsych.pluginAPI.cancelKeyboardResponse(response_listener); + + var trial_data = { + animation_sequence: animation_sequence, + response: responses + }; + + jsPsych.finishTrial(trial_data); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-audio-button-response.js b/scripts/plugins/jspsych-audio-button-response.js new file mode 100644 index 0000000..a7146a2 --- /dev/null +++ b/scripts/plugins/jspsych-audio-button-response.js @@ -0,0 +1,269 @@ +/** + * jspsych-audio-button-response + * Kristin Diep + * + * plugin for playing an audio file and getting a keyboard response + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["audio-button-response"] = (function () { + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('audio-button-response', 'stimulus', 'audio'); + + plugin.info = { + name: 'audio-button-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.AUDIO, + pretty_name: 'Stimulus', + default: undefined, + description: 'The audio to be played.' + }, + choices: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Choices', + default: undefined, + array: true, + description: 'The button labels.' + }, + button_html: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Button HTML', + default: '', + array: true, + description: 'Custom button. Can make your own style.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'The maximum duration to wait for a response.' + }, + margin_vertical: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin vertical', + default: '0px', + description: 'Vertical margin of button.' + }, + margin_horizontal: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin horizontal', + default: '8px', + description: 'Horizontal margin of button.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, the trial will end when user makes a response.' + }, + trial_ends_after_audio: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Trial ends after audio', + default: false, + description: 'If true, then the trial will end as soon as the audio file finishes playing.' + }, + response_allowed_while_playing: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response allowed while playing', + default: true, + description: 'If true, then responses are allowed while the audio is playing. ' + + 'If false, then the audio must finish playing before a response is accepted.' + } + } + } + + plugin.trial = function (display_element, trial) { + + // setup stimulus + var context = jsPsych.pluginAPI.audioContext(); + var audio; + + // store response + var response = { + rt: null, + button: null + }; + + // record webaudio context start time + var startTime; + + // load audio file + jsPsych.pluginAPI.getAudioBuffer(trial.stimulus) + .then(function (buffer) { + if (context !== null) { + audio = context.createBufferSource(); + audio.buffer = buffer; + audio.connect(context.destination); + } else { + audio = buffer; + audio.currentTime = 0; + } + setupTrial(); + }) + .catch(function (err) { + console.error(`Failed to load audio file "${trial.stimulus}". Try checking the file path. We recommend using the preload plugin to load audio files.`) + console.error(err) + }); + + function setupTrial() { + // set up end event if trial needs it + if (trial.trial_ends_after_audio) { + audio.addEventListener('ended', end_trial); + } + + // enable buttons after audio ends if necessary + if ((!trial.response_allowed_while_playing) & (!trial.trial_ends_after_audio)) { + audio.addEventListener('ended', enable_buttons); + } + + //display buttons + var buttons = []; + if (Array.isArray(trial.button_html)) { + if (trial.button_html.length == trial.choices.length) { + buttons = trial.button_html; + } else { + console.error('Error in audio-button-response plugin. The length of the button_html array does not equal the length of the choices array'); + } + } else { + for (var i = 0; i < trial.choices.length; i++) { + buttons.push(trial.button_html); + } + } + + var html = '
'; + for (var i = 0; i < trial.choices.length; i++) { + var str = buttons[i].replace(/%choice%/g, trial.choices[i]); + html += '
' + str + '
'; + } + html += '
'; + + //show prompt if there is one + if (trial.prompt !== null) { + html += trial.prompt; + } + + display_element.innerHTML = html; + + if (trial.response_allowed_while_playing) { + enable_buttons(); + } else { + disable_buttons(); + } + + // start time + startTime = performance.now(); + + // start audio + if (context !== null) { + startTime = context.currentTime; + audio.start(startTime); + } else { + audio.play(); + } + + // end trial if time limit is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + end_trial(); + }, trial.trial_duration); + } + } + + + + // function to handle responses by the subject + function after_response(choice) { + + // measure rt + var endTime = performance.now(); + var rt = endTime - startTime; + if (context !== null) { + endTime = context.currentTime; + rt = Math.round((endTime - startTime) * 1000); + } + response.button = parseInt(choice); + response.rt = rt; + + // disable all the buttons after a response + disable_buttons(); + + if (trial.response_ends_trial) { + end_trial(); + } + } + + // function to end trial when it is time + function end_trial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // stop the audio file if it is playing + // remove end event listeners if they exist + if (context !== null) { + audio.stop(); + } else { + audio.pause(); + } + + audio.removeEventListener('ended', end_trial); + audio.removeEventListener('ended', enable_buttons); + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.button + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + } + + function button_response(e) { + var choice = e.currentTarget.getAttribute('data-choice'); // don't use dataset for jsdom compatibility + after_response(choice); + } + + function disable_buttons() { + var btns = document.querySelectorAll('.jspsych-audio-button-response-button'); + for (var i = 0; i < btns.length; i++) { + var btn_el = btns[i].querySelector('button'); + if (btn_el) { + btn_el.disabled = true; + } + btns[i].removeEventListener('click', button_response); + } + } + + function enable_buttons() { + var btns = document.querySelectorAll('.jspsych-audio-button-response-button'); + for (var i = 0; i < btns.length; i++) { + var btn_el = btns[i].querySelector('button'); + if (btn_el) { + btn_el.disabled = false; + } + btns[i].addEventListener('click', button_response); + } + } + + + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-audio-keyboard-response.js b/scripts/plugins/jspsych-audio-keyboard-response.js new file mode 100644 index 0000000..75d8014 --- /dev/null +++ b/scripts/plugins/jspsych-audio-keyboard-response.js @@ -0,0 +1,212 @@ +/** + * jspsych-audio-keyboard-response + * Josh de Leeuw + * + * plugin for playing an audio file and getting a keyboard response + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["audio-keyboard-response"] = (function () { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('audio-keyboard-response', 'stimulus', 'audio'); + + plugin.info = { + name: 'audio-keyboard-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.AUDIO, + pretty_name: 'Stimulus', + default: undefined, + description: 'The audio to be played.' + }, + choices: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Choices', + array: true, + default: jsPsych.ALL_KEYS, + description: 'The keys the subject is allowed to press to respond to the stimulus.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'The maximum duration to wait for a response.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, the trial will end when user makes a response.' + }, + trial_ends_after_audio: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Trial ends after audio', + default: false, + description: 'If true, then the trial will end as soon as the audio file finishes playing.' + }, + response_allowed_while_playing: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response allowed while playing', + default: true, + description: 'If true, then responses are allowed while the audio is playing. ' + + 'If false, then the audio must finish playing before a response is accepted.' + } + } + } + + plugin.trial = function (display_element, trial) { + + // setup stimulus + var context = jsPsych.pluginAPI.audioContext(); + var audio; + + // store response + var response = { + rt: null, + key: null + }; + + // record webaudio context start time + var startTime; + + // load audio file + jsPsych.pluginAPI.getAudioBuffer(trial.stimulus) + .then(function (buffer) { + if (context !== null) { + audio = context.createBufferSource(); + audio.buffer = buffer; + audio.connect(context.destination); + } else { + audio = buffer; + audio.currentTime = 0; + } + setupTrial(); + }) + .catch(function (err) { + console.error(`Failed to load audio file "${trial.stimulus}". Try checking the file path. We recommend using the preload plugin to load audio files.`) + console.error(err) + }); + + function setupTrial() { + // set up end event if trial needs it + if (trial.trial_ends_after_audio) { + audio.addEventListener('ended', end_trial); + } + + // show prompt if there is one + if (trial.prompt !== null) { + display_element.innerHTML = trial.prompt; + } + + // start audio + if (context !== null) { + startTime = context.currentTime; + audio.start(startTime); + } else { + audio.play(); + } + + // start keyboard listener when trial starts or sound ends + if (trial.response_allowed_while_playing) { + setup_keyboard_listener(); + } else if (!trial.trial_ends_after_audio) { + audio.addEventListener('ended', setup_keyboard_listener); + } + + // end trial if time limit is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + end_trial(); + }, trial.trial_duration); + } + } + + + // function to end trial when it is time + function end_trial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // stop the audio file if it is playing + // remove end event listeners if they exist + if (context !== null) { + audio.stop(); + } else { + audio.pause(); + } + + audio.removeEventListener('ended', end_trial); + audio.removeEventListener('ended', setup_keyboard_listener); + + + // kill keyboard listeners + jsPsych.pluginAPI.cancelAllKeyboardResponses(); + + // gather the data to store for the trial + if (context !== null && response.rt !== null) { + response.rt = Math.round(response.rt * 1000); + } + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.key + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + } + + // function to handle responses by the subject + function after_response(info) { + + // only record the first response + if (response.key == null) { + response = info; + } + + if (trial.response_ends_trial) { + end_trial(); + } + }; + + function setup_keyboard_listener() { + // start the response listener + if (context !== null) { + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'audio', + persist: false, + allow_held_key: false, + audio_context: context, + audio_context_start_time: startTime + }); + } else { + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-audio-slider-response.js b/scripts/plugins/jspsych-audio-slider-response.js new file mode 100644 index 0000000..a3792a6 --- /dev/null +++ b/scripts/plugins/jspsych-audio-slider-response.js @@ -0,0 +1,278 @@ +jsPsych.plugins['audio-slider-response'] = (function () { + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('audio-slider-response', 'stimulus', 'audio'); + + plugin.info = { + name: 'audio-slider-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.AUDIO, + pretty_name: 'Stimulus', + default: undefined, + description: 'The image to be displayed' + }, + min: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Min slider', + default: 0, + description: 'Sets the minimum value of the slider.' + }, + max: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Max slider', + default: 100, + description: 'Sets the maximum value of the slider', + }, + slider_start: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Slider starting value', + default: 50, + description: 'Sets the starting value of the slider', + }, + step: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Step', + default: 1, + description: 'Sets the step of the slider' + }, + labels: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Labels', + default: [], + array: true, + description: 'Labels of the slider.', + }, + slider_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Slider width', + default: null, + description: 'Width of the slider in pixels.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + array: false, + description: 'Label of the button to advance.' + }, + require_movement: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Require movement', + default: false, + description: 'If true, the participant will have to move the slider before continuing.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the slider.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, trial will end when user makes a response.' + }, + response_allowed_while_playing: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response allowed while playing', + default: true, + description: 'If true, then responses are allowed while the audio is playing. ' + + 'If false, then the audio must finish playing before a response is accepted.' + } + } + } + + plugin.trial = function (display_element, trial) { + + // half of the thumb width value from jspsych.css, used to adjust the label positions + var half_thumb_width = 7.5; + + // setup stimulus + var context = jsPsych.pluginAPI.audioContext(); + var audio; + + // record webaudio context start time + var startTime; + + // for storing data related to response + var response; + + + // load audio file + jsPsych.pluginAPI.getAudioBuffer(trial.stimulus) + .then(function (buffer) { + if (context !== null) { + audio = context.createBufferSource(); + audio.buffer = buffer; + audio.connect(context.destination); + } else { + audio = buffer; + audio.currentTime = 0; + } + setupTrial(); + }) + .catch(function (err) { + console.error(`Failed to load audio file "${trial.stimulus}". Try checking the file path. We recommend using the preload plugin to load audio files.`) + console.error(err) + }); + + function setupTrial() { + + + // set up end event if trial needs it + if (trial.trial_ends_after_audio) { + + audio.addEventListener('ended', end_trial); + + } + + // enable slider after audio ends if necessary + if ((!trial.response_allowed_while_playing) & (!trial.trial_ends_after_audio)) { + + audio.addEventListener('ended', enable_slider); + + } + + var html = '
'; + html += '
'; + html += ''; + html += '' + trial.labels[j] + ''; + html += '
' + } + html += '
'; + html += '
'; + html += ''; + + if (trial.prompt !== null) { + html += trial.prompt; + } + + // add submit button + var next_disabled_attribute = ""; + if (trial.require_movement | !trial.response_allowed_while_playing) { + next_disabled_attribute = "disabled"; + } + html += ''; + + display_element.innerHTML = html; + + response = { + rt: null, + response: null + }; + + if (!trial.response_allowed_while_playing) { + display_element.querySelector('#jspsych-audio-slider-response-response').disabled = true; + display_element.querySelector('#jspsych-audio-slider-response-next').disabled = true; + } + + if (trial.require_movement) { + display_element.querySelector('#jspsych-audio-slider-response-response').addEventListener('click', function () { + display_element.querySelector('#jspsych-audio-slider-response-next').disabled = false; + }); + } + + display_element.querySelector('#jspsych-audio-slider-response-next').addEventListener('click', function () { + // measure response time + var endTime = performance.now(); + var rt = endTime - startTime; + if (context !== null) { + endTime = context.currentTime; + rt = Math.round((endTime - startTime) * 1000); + } + response.rt = rt; + response.response = display_element.querySelector('#jspsych-audio-slider-response-response').valueAsNumber; + + if (trial.response_ends_trial) { + end_trial(); + } else { + display_element.querySelector('#jspsych-audio-slider-response-next').disabled = true; + } + + }); + + startTime = performance.now(); + // start audio + if (context !== null) { + startTime = context.currentTime; + audio.start(startTime); + } else { + audio.play(); + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + end_trial(); + }, trial.trial_duration); + } + } + + // function to enable slider after audio ends + function enable_slider() { + document.querySelector('#jspsych-audio-slider-response-response').disabled = false; + if (!trial.require_movement) { + document.querySelector('#jspsych-audio-slider-response-next').disabled = false; + } + } + + function end_trial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // stop the audio file if it is playing + // remove end event listeners if they exist + if (context !== null) { + audio.stop(); + } else { + audio.pause(); + } + + audio.removeEventListener('ended', end_trial); + audio.removeEventListener('ended', enable_slider); + + + // save data + var trialdata = { + rt: response.rt, + stimulus: trial.stimulus, + slider_start: trial.slider_start, + response: response.response + }; + + display_element.innerHTML = ''; + + // next trial + jsPsych.finishTrial(trialdata); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-call-function.js b/scripts/plugins/jspsych-call-function.js index c85ea0b..cade2b5 100644 --- a/scripts/plugins/jspsych-call-function.js +++ b/scripts/plugins/jspsych-call-function.js @@ -1,41 +1,58 @@ -/** +/** * jspsych-call-function * plugin for calling an arbitrary function during a jspsych experiment * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-call-function - * -**/ + * + * documentation: docs.jspsych.org + * + **/ -(function($) { - jsPsych['call-function'] = (function() { +jsPsych.plugins['call-function'] = (function() { - var plugin = {}; + var plugin = {}; - plugin.create = function(params) { - var trials = new Array(1); - trials[0] = { - "type": "call-function", - "func": params.func, - "args": params.args || [], - "data": (typeof params.data === 'undefined') ? {} : params.data - }; - return trials; - }; + plugin.info = { + name: 'call-function', + description: '', + parameters: { + func: { + type: jsPsych.plugins.parameterType.FUNCTION, + pretty_name: 'Function', + default: undefined, + description: 'Function to call' + }, + async: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Asynchronous', + default: false, + description: 'Is the function call asynchronous?' + } + } + } - plugin.trial = function(display_element, block, trial, part) { - var return_val = trial.func.apply({}, [trial.args]); - if (typeof return_val !== 'undefined') { - block.writeData($.extend({},{ - trial_type: "call-function", - trial_index: block.trial_idx, - value: return_val - },trial.data)); - } + plugin.trial = function(display_element, trial) { + trial.post_trial_gap = 0; + var return_val; - block.next(); - }; + if(trial.async){ + var done = function(data){ + return_val = data; + end_trial(); + } + trial.func(done); + } else { + return_val = trial.func(); + end_trial(); + } + + function end_trial(){ + var trial_data = { + value: return_val + }; + + jsPsych.finishTrial(trial_data); + } + }; - return plugin; - })(); -})(jQuery); + return plugin; +})(); diff --git a/scripts/plugins/jspsych-canvas-button-response.js b/scripts/plugins/jspsych-canvas-button-response.js new file mode 100644 index 0000000..c0a0518 --- /dev/null +++ b/scripts/plugins/jspsych-canvas-button-response.js @@ -0,0 +1,199 @@ +/** + * jspsych-canvas-button-response + * Chris Jungerius (modified from Josh de Leeuw) + * + * a jsPsych plugin for displaying a canvas stimulus and getting a button response + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["canvas-button-response"] = (function () { + + var plugin = {}; + + plugin.info = { + name: 'canvas-button-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.FUNCTION, + pretty_name: 'Stimulus', + default: undefined, + description: 'The drawing function to apply to the canvas. Should take the canvas object as argument.' + }, + choices: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Choices', + default: undefined, + array: true, + description: 'The labels for the buttons.' + }, + button_html: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button HTML', + default: '', + array: true, + description: 'The html of the button. Can create own style.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed under the button.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + margin_vertical: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin vertical', + default: '0px', + description: 'The vertical margin of the button.' + }, + margin_horizontal: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin horizontal', + default: '8px', + description: 'The horizontal margin of the button.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, then trial will end when user responds.' + }, + canvas_size: { + type: jsPsych.plugins.parameterType.INT, + array: true, + pretty_name: 'Canvas size', + default: [500, 500], + description: 'Array containing the height (first value) and width (second value) of the canvas element.' + } + + } + } + + plugin.trial = function (display_element, trial) { + + // create canvas + var html = '
' + '' + '
'; + + //display buttons + var buttons = []; + if (Array.isArray(trial.button_html)) { + if (trial.button_html.length == trial.choices.length) { + buttons = trial.button_html; + } else { + console.error('Error in canvas-button-response plugin. The length of the button_html array does not equal the length of the choices array'); + } + } else { + for (var i = 0; i < trial.choices.length; i++) { + buttons.push(trial.button_html); + } + } + html += '
'; + for (var i = 0; i < trial.choices.length; i++) { + var str = buttons[i].replace(/%choice%/g, trial.choices[i]); + html += '
' + str + '
'; + } + html += '
'; + + //show prompt if there is one + if (trial.prompt !== null) { + html += trial.prompt; + } + display_element.innerHTML = html; + + //draw + let c = document.getElementById("jspsych-canvas-stimulus") + trial.stimulus(c) + + // start time + var start_time = performance.now(); + + // add event listeners to buttons + for (var i = 0; i < trial.choices.length; i++) { + display_element.querySelector('#jspsych-canvas-button-response-button-' + i).addEventListener('click', function (e) { + var choice = e.currentTarget.getAttribute('data-choice'); // don't use dataset for jsdom compatibility + after_response(choice); + }); + } + + // store response + var response = { + rt: null, + button: null + }; + + // function to handle responses by the subject + function after_response(choice) { + + // measure rt + var end_time = performance.now(); + var rt = end_time - start_time; + response.button = parseInt(choice); + response.rt = rt; + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-canvas-button-response-stimulus').className += ' responded'; + + // disable all the buttons after a response + var btns = document.querySelectorAll('.jspsych-canvas-button-response-button button'); + for (var i = 0; i < btns.length; i++) { + //btns[i].removeEventListener('click'); + btns[i].setAttribute('disabled', 'disabled'); + } + + if (trial.response_ends_trial) { + end_trial(); + } + }; + + // function to end trial when it is time + function end_trial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + response: response.button + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + // hide image if timing is set + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + display_element.querySelector('#jspsych-canvas-button-response-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // end trial if time limit is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + end_trial(); + }, trial.trial_duration); + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-canvas-keyboard-response.js b/scripts/plugins/jspsych-canvas-keyboard-response.js new file mode 100644 index 0000000..08833f5 --- /dev/null +++ b/scripts/plugins/jspsych-canvas-keyboard-response.js @@ -0,0 +1,155 @@ +/** + * jspsych-canvas-keyboard-response + * Chris Jungerius (modified from Josh de Leeuw) + * + * a jsPsych plugin for displaying a canvas stimulus and getting a keyboard response + * + * documentation: docs.jspsych.org + * + **/ + + +jsPsych.plugins["canvas-keyboard-response"] = (function () { + + var plugin = {}; + + plugin.info = { + name: 'canvas-keyboard-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.FUNCTION, + pretty_name: 'Stimulus', + default: undefined, + description: 'The drawing function to apply to the canvas. Should take the canvas object as argument.' + }, + choices: { + type: jsPsych.plugins.parameterType.KEY, + array: true, + pretty_name: 'Choices', + default: jsPsych.ALL_KEYS, + description: 'The keys the subject is allowed to press to respond to the stimulus.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show trial before it ends.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, trial will end when subject makes a response.' + }, + canvas_size: { + type: jsPsych.plugins.parameterType.INT, + array: true, + pretty_name: 'Canvas size', + default: [500, 500], + description: 'Array containing the height (first value) and width (second value) of the canvas element.' + } + + } + } + + plugin.trial = function (display_element, trial) { + + var new_html = '
' + '' + '
'; + // add prompt + if (trial.prompt !== null) { + new_html += trial.prompt; + } + + // draw + display_element.innerHTML = new_html; + let c = document.getElementById("jspsych-canvas-stimulus") + trial.stimulus(c) + // store response + var response = { + rt: null, + key: null + }; + + // function to end trial when it is time + var end_trial = function () { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // kill keyboard listeners + if (typeof keyboardListener !== 'undefined') { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener); + } + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + response: response.key + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + // function to handle responses by the subject + var after_response = function (info) { + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-canvas-keyboard-response-stimulus').className += ' responded'; + + // only record the first response + if (response.key == null) { + response = info; + } + + if (trial.response_ends_trial) { + end_trial(); + } + }; + + // start the response listener + if (trial.choices != jsPsych.NO_KEYS) { + var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + + // hide stimulus if stimulus_duration is set + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + display_element.querySelector('#jspsych-canvas-keyboard-response-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + end_trial(); + }, trial.trial_duration); + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-canvas-slider-response.js b/scripts/plugins/jspsych-canvas-slider-response.js new file mode 100644 index 0000000..4746b7c --- /dev/null +++ b/scripts/plugins/jspsych-canvas-slider-response.js @@ -0,0 +1,207 @@ +/** + * jspsych-canvas-slider-response + * Chris Jungerius (modified from Josh de Leeuw) + * + * a jsPsych plugin for displaying a canvas stimulus and getting a slider response + * + * documentation: docs.jspsych.org + * + */ + + +jsPsych.plugins['canvas-slider-response'] = (function () { + + var plugin = {}; + + plugin.info = { + name: 'canvas-slider-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.FUNCTION, + pretty_name: 'Stimulus', + default: undefined, + description: 'The drawing function to apply to the canvas. Should take the canvas object as argument.' + }, + min: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Min slider', + default: 0, + description: 'Sets the minimum value of the slider.' + }, + max: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Max slider', + default: 100, + description: 'Sets the maximum value of the slider', + }, + slider_start: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Slider starting value', + default: 50, + description: 'Sets the starting value of the slider', + }, + step: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Step', + default: 1, + description: 'Sets the step of the slider' + }, + labels: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Labels', + default: [], + array: true, + description: 'Labels of the slider.', + }, + slider_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Slider width', + default: null, + description: 'Width of the slider in pixels.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + array: false, + description: 'Label of the button to advance.' + }, + require_movement: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Require movement', + default: false, + description: 'If true, the participant will have to move the slider before continuing.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the slider.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, trial will end when user makes a response.' + }, + canvas_size: { + type: jsPsych.plugins.parameterType.INT, + array: true, + pretty_name: 'Canvas size', + default: [500, 500], + description: 'Array containing the height (first value) and width (second value) of the canvas element.' + } + + } + } + + plugin.trial = function (display_element, trial) { + + var html = '
'; + html += '
' + '' + '
'; + html += '
'; + html += ''; + html += '
' + for (var j = 0; j < trial.labels.length; j++) { + var width = 100 / (trial.labels.length - 1); + var left_offset = (j * (100 / (trial.labels.length - 1))) - (width / 2); + html += '
'; + html += '' + trial.labels[j] + ''; + html += '
' + } + html += '
'; + html += '
'; + html += '
'; + + if (trial.prompt !== null) { + html += trial.prompt; + } + + // add submit button + html += ''; + + display_element.innerHTML = html; + + // draw + let c = document.getElementById("jspsych-canvas-stimulus") + trial.stimulus(c) + + var response = { + rt: null, + response: null + }; + + if (trial.require_movement) { + display_element.querySelector('#jspsych-canvas-slider-response-response').addEventListener('click', function () { + display_element.querySelector('#jspsych-canvas-slider-response-next').disabled = false; + }) + } + + display_element.querySelector('#jspsych-canvas-slider-response-next').addEventListener('click', function () { + // measure response time + var endTime = performance.now(); + response.rt = endTime - startTime; + response.response = display_element.querySelector('#jspsych-canvas-slider-response-response').valueAsNumber; + + if (trial.response_ends_trial) { + end_trial(); + } else { + display_element.querySelector('#jspsych-canvas-slider-response-next').disabled = true; + } + + }); + + function end_trial() { + + jsPsych.pluginAPI.clearAllTimeouts(); + + // save data + var trialdata = { + rt: response.rt, + response: response.response, + slider_start: trial.slider_start + }; + + display_element.innerHTML = ''; + + // next trial + jsPsych.finishTrial(trialdata); + } + + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + display_element.querySelector('#jspsych-canvas-slider-response-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function () { + end_trial(); + }, trial.trial_duration); + } + + var startTime = performance.now(); + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-categorize-animation.js b/scripts/plugins/jspsych-categorize-animation.js index f4023b2..b7af7d4 100644 --- a/scripts/plugins/jspsych-categorize-animation.js +++ b/scripts/plugins/jspsych-categorize-animation.js @@ -1,166 +1,266 @@ -/** +/** * jspsych plugin for categorization trials with feedback and animated stimuli * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-categorize-animation + * + * documentation: docs.jspsych.org **/ -(function($) { - jsPsych["categorize-animation"] = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['key_answer','text_answer','choices','data']); - - var trials = new Array(params.stimuli.length); - for (var i = 0; i < trials.length; i++) { - trials[i] = {}; - trials[i].type = "categorize-animation"; - trials[i].stims = params.stimuli[i]; - trials[i].reps = params.reps || 1; - trials[i].key_answer = params.key_answer[i]; - trials[i].text_answer = (typeof params.text_answer === 'undefined') ? "" : params.text_answer[i]; - trials[i].choices = params.choices; - trials[i].correct_text = params.correct_text || "Correct."; - trials[i].incorrect_text = params.incorrect_text || "Wrong."; - trials[i].allow_response_before_complete = params.allow_response_before_complete || false; - trials[i].frame_time = params.frame_time || 500; - trials[i].timing_feedback_duration = params.timing_feedback_duration || 2000; - trials[i].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial; - trials[i].prompt = (typeof params.prompt === 'undefined') ? '' : params.prompt; - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data[i]; - } - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - var animate_frame = -1; - var reps = 0; - - var showAnimation = true; - - var responded = false; - var timeoutSet = false; - - - var startTime = (new Date()).getTime(); - - // show animation - var animate_interval = setInterval(function() { - display_element.html(""); // clear everything - animate_frame++; - if (animate_frame == trial.stims.length) { - animate_frame = 0; - reps++; - // check if reps complete // - if (trial.reps != -1 && reps >= trial.reps) { - // done with animation - showAnimation = false; - } - } - - if (showAnimation) { - display_element.append($('', { - "src": trial.stims[animate_frame], - "class": 'jspsych-categorize-animation-stimulus' - })); - } - - if (!responded && trial.allow_response_before_complete) { - // in here if the user can respond before the animation is done - if (trial.prompt !== "") { - display_element.append(trial.prompt); - } - } - else if (!responded) { - // in here if the user has to wait to respond until animation is done. - // if this is the case, don't show the prompt until the animation is over. - if (!showAnimation) { - if (trial.prompt !== "") { - display_element.append(trial.prompt); - } - } - } - else { - // user has responded if we get here. - - // show feedback - var feedback_text = ""; - if (block.data[block.trial_idx].correct) { - feedback_text = trial.correct_text.replace("%ANS%", trial.text_answer); - } - else { - feedback_text = trial.incorrect_text.replace("%ANS%", trial.text_answer); - } - display_element.append(feedback_text); - - // set timeout to clear feedback - if (!timeoutSet) { - timeoutSet = true; - setTimeout(function() { - endTrial(); - }, trial.timing_feedback_duration); - } - } - - - }, trial.frame_time); - - - var keyboard_listener; - - var after_response = function(info){ - // ignore the response if animation is playing and subject - // not allowed to respond before it is complete - if (!trial.allow_response_before_complete && showAnimation) { - return false; - } - - var correct = false; - if(trial.key_answer == info.key) { - correct = true; - } - - responded = true; - - var trial_data = { - "trial_type": trial.type, - "trial_index": block.trial_idx, - "stimulus": trial.stims[0], - "rt": info.rt, - "correct": correct, - "key_press": info.key - }; - - block.writeData($.extend({}, trial_data, trial.data)); - - jsPsych.pluginAPI.cancelKeyboardResponse(keyboard_listener); - - } - - jsPsych.pluginAPI.getKeyboardResponse(after_response, trial.choices, 'date', true); - - function endTrial() { - clearInterval(animate_interval); // stop animation! - display_element.html(''); // clear everything - if(trial.timing_post_trial > 0){ - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } else { - block.next(); - } + +jsPsych.plugins["categorize-animation"] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('categorize-animation', 'stimuli', 'image'); + + plugin.info = { + name: 'categorize-animation', + description: '', + parameters: { + stimuli: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimuli', + default: undefined, + description: 'Array of paths to image files.' + }, + key_answer: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key answer', + default: undefined, + description: 'The key to indicate correct response' + }, + choices: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Choices', + default: jsPsych.ALL_KEYS, + array: true, + description: 'The keys subject is allowed to press to respond to stimuli.' + }, + text_answer: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Text answer', + default: null, + description: 'Text to describe correct answer.' + }, + correct_text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Correct text', + default: 'Correct.', + description: 'String to show when subject gives correct answer' + }, + incorrect_text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Incorrect text', + default: 'Wrong.', + description: 'String to show when subject gives incorrect answer.' + }, + frame_time: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Frame time', + default: 500, + description: 'Duration to display each image.' + }, + sequence_reps: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Sequence repetitions', + default: 1, + description: 'How many times to display entire sequence.' + }, + allow_response_before_complete: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Allow response before complete', + default: false, + description: 'If true, subject can response before the animation sequence finishes' + }, + feedback_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Feedback duration', + default: 2000, + description: 'How long to show feedback' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + }, + render_on_canvas: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Render on canvas', + default: true, + description: 'If true, the images will be drawn onto a canvas element (prevents blank screen between consecutive images in some browsers).'+ + 'If false, the image will be shown via an img element.' + } + } + } + + plugin.trial = function(display_element, trial) { + + var animate_frame = -1; + var reps = 0; + + var showAnimation = true; + + var responded = false; + var timeoutSet = false; + var correct; + + if (trial.render_on_canvas) { + // first clear the display element (because the render_on_canvas method appends to display_element instead of overwriting it with .innerHTML) + if (display_element.hasChildNodes()) { + // can't loop through child list because the list will be modified by .removeChild() + while (display_element.firstChild) { + display_element.removeChild(display_element.firstChild); + } + } + var canvas = document.createElement("canvas"); + canvas.id = "jspsych-categorize-animation-stimulus"; + canvas.style.margin = 0; + canvas.style.padding = 0; + display_element.insertBefore(canvas, null); + var ctx = canvas.getContext("2d"); + if (trial.prompt !== null) { + var prompt_div = document.createElement("div"); + prompt_div.id = "jspsych-categorize-animation-prompt"; + prompt_div.style.visibility = "hidden"; + prompt_div.innerHTML = trial.prompt; + display_element.insertBefore(prompt_div, canvas.nextElementSibling); + } + var feedback_div = document.createElement("div"); + display_element.insertBefore(feedback_div, display_element.nextElementSibling); + } + + // show animation + var animate_interval = setInterval(function() { + if (!trial.render_on_canvas) { + display_element.innerHTML = ''; // clear everything + } + animate_frame++; + if (animate_frame == trial.stimuli.length) { + animate_frame = 0; + reps++; + // check if reps complete // + if (trial.sequence_reps != -1 && reps >= trial.sequence_reps) { + // done with animation + showAnimation = false; + } + } + + if (showAnimation) { + if (trial.render_on_canvas) { + display_element.querySelector('#jspsych-categorize-animation-stimulus').style.visibility = 'visible'; + var img = new Image(); + img.src = trial.stimuli[animate_frame]; + canvas.height = img.naturalHeight; + canvas.width = img.naturalWidth; + ctx.drawImage(img,0,0); + } else { + display_element.innerHTML += ''; + } + } + + if (!responded && trial.allow_response_before_complete) { + // in here if the user can respond before the animation is done + if (trial.prompt !== null) { + if (trial.render_on_canvas) { + prompt_div.style.visibility = "visible"; + } else { + display_element.innerHTML += trial.prompt; + } + } + if (trial.render_on_canvas) { + if (!showAnimation) { + canvas.remove(); + } + } + } else if (!responded) { + // in here if the user has to wait to respond until animation is done. + // if this is the case, don't show the prompt until the animation is over. + if (!showAnimation) { + if (trial.prompt !== null) { + if (trial.render_on_canvas) { + prompt_div.style.visibility = "visible"; + } else { + display_element.innerHTML += trial.prompt; } - }; + } + if (trial.render_on_canvas) { + canvas.remove(); + } + } + } else { + // user has responded if we get here. + + // show feedback + var feedback_text = ""; + if (correct) { + feedback_text = trial.correct_text.replace("%ANS%", trial.text_answer); + } else { + feedback_text = trial.incorrect_text.replace("%ANS%", trial.text_answer); + } + if (trial.render_on_canvas) { + if (trial.prompt !== null) { + prompt_div.remove(); + } + feedback_div.innerHTML = feedback_text; + } else { + display_element.innerHTML += feedback_text; + } + + // set timeout to clear feedback + if (!timeoutSet) { + timeoutSet = true; + jsPsych.pluginAPI.setTimeout(function() { + endTrial(); + }, trial.feedback_duration); + } + } + + + }, trial.frame_time); + + + var keyboard_listener; + var trial_data = {}; + + var after_response = function(info) { + // ignore the response if animation is playing and subject + // not allowed to respond before it is complete + if (!trial.allow_response_before_complete && showAnimation) { + return false; + } + + correct = false; + if (jsPsych.pluginAPI.compareKeys(trial.key_answer, info.key)) { + correct = true; + } + + responded = true; + + trial_data = { + stimulus: trial.stimuli, + rt: info.rt, + correct: correct, + response: info.key + }; + + jsPsych.pluginAPI.cancelKeyboardResponse(keyboard_listener); + + } + + keyboard_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: true, + allow_held_key: false + }); + + function endTrial() { + clearInterval(animate_interval); // stop animation! + display_element.innerHTML = ''; // clear everything + jsPsych.finishTrial(trial_data); + } + }; - return plugin; - })(); -})(jQuery); + return plugin; +})(); diff --git a/scripts/plugins/jspsych-categorize-html.js b/scripts/plugins/jspsych-categorize-html.js new file mode 100644 index 0000000..7ba5fdb --- /dev/null +++ b/scripts/plugins/jspsych-categorize-html.js @@ -0,0 +1,220 @@ +/** + * jspsych plugin for categorization trials with feedback + * Josh de Leeuw + * + * documentation: docs.jspsych.org + **/ + + +jsPsych.plugins['categorize-html'] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'categorize-html', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Stimulus', + default: undefined, + description: 'The HTML content to be displayed.' + }, + key_answer: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key answer', + default: undefined, + description: 'The key to indicate the correct response.' + }, + choices: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Choices', + default: jsPsych.ALL_KEYS, + array: true, + description: 'The keys the subject is allowed to press to respond to the stimulus.' + }, + text_answer: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Text answer', + default: null, + description: 'Label that is associated with the correct answer.' + }, + correct_text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Correct text', + default: "

Correct

", + description: 'String to show when correct answer is given.' + }, + incorrect_text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Incorrect text', + default: "

Incorrect

", + description: 'String to show when incorrect answer is given.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + }, + force_correct_button_press: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Force correct button press', + default: false, + description: 'If set to true, then the subject must press the correct response key after feedback in order to advance to next trial.' + }, + show_stim_with_feedback: { + type: jsPsych.plugins.parameterType.BOOL, + default: true, + no_function: false, + description: '' + }, + show_feedback_on_timeout: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Show feedback on timeout', + default: false, + description: 'If true, stimulus will be shown during feedback. If false, only the text feedback will be displayed during feedback.' + }, + timeout_message: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Timeout message', + default: "

Please respond faster.

", + description: 'The message displayed on a timeout non-response.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show trial' + }, + feedback_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Feedback duration', + default: 2000, + description: 'How long to show feedback.' + } + } + } + + plugin.trial = function(display_element, trial) { + + display_element.innerHTML = '
'+trial.stimulus+'
'; + + // hide image after time if the timing parameter is set + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('#jspsych-categorize-html-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // if prompt is set, show prompt + if (trial.prompt !== null) { + display_element.innerHTML += trial.prompt; + } + + var trial_data = {}; + + // create response function + var after_response = function(info) { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // clear keyboard listener + jsPsych.pluginAPI.cancelAllKeyboardResponses(); + + var correct = false; + if (jsPsych.pluginAPI.compareKeys(trial.key_answer,info.key)) { + correct = true; + } + + // save data + trial_data = { + rt: info.rt, + correct: correct, + stimulus: trial.stimulus, + response: info.key + }; + + display_element.innerHTML = ''; + + var timeout = info.rt == null; + doFeedback(correct, timeout); + } + + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + after_response({ + key: null, + rt: null + }); + }, trial.trial_duration); + } + + function doFeedback(correct, timeout) { + + if (timeout && !trial.show_feedback_on_timeout) { + display_element.innerHTML += trial.timeout_message; + } else { + // show image during feedback if flag is set + if (trial.show_stim_with_feedback) { + display_element.innerHTML = '
'+trial.stimulus+'
'; + } + + // substitute answer in feedback string. + var atext = ""; + if (correct) { + atext = trial.correct_text.replace("%ANS%", trial.text_answer); + } else { + atext = trial.incorrect_text.replace("%ANS%", trial.text_answer); + } + + // show the feedback + display_element.innerHTML += atext; + } + // check if force correct button press is set + if (trial.force_correct_button_press && correct === false && ((timeout && trial.show_feedback_on_timeout) || !timeout)) { + + var after_forced_response = function(info) { + endTrial(); + } + + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_forced_response, + valid_responses: [trial.key_answer], + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + + } else { + jsPsych.pluginAPI.setTimeout(function() { + endTrial(); + }, trial.feedback_duration); + } + + } + + function endTrial() { + display_element.innerHTML = ''; + jsPsych.finishTrial(trial_data); + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-categorize-image.js b/scripts/plugins/jspsych-categorize-image.js new file mode 100644 index 0000000..6ddab6f --- /dev/null +++ b/scripts/plugins/jspsych-categorize-image.js @@ -0,0 +1,222 @@ +/** + * jspsych plugin for categorization trials with feedback + * Josh de Leeuw + * + * documentation: docs.jspsych.org + **/ + + +jsPsych.plugins['categorize-image'] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('categorize-image', 'stimulus', 'image'); + + plugin.info = { + name: 'categorize-image', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimulus', + default: undefined, + description: 'The image content to be displayed.' + }, + key_answer: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key answer', + default: undefined, + description: 'The key to indicate the correct response.' + }, + choices: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Choices', + default: jsPsych.ALL_KEYS, + array: true, + description: 'The keys the subject is allowed to press to respond to the stimulus.' + }, + text_answer: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Text answer', + default: null, + description: 'Label that is associated with the correct answer.' + }, + correct_text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Correct text', + default: "

Correct

", + description: 'String to show when correct answer is given.' + }, + incorrect_text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Incorrect text', + default: "

Incorrect

", + description: 'String to show when incorrect answer is given.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + }, + force_correct_button_press: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Force correct button press', + default: false, + description: 'If set to true, then the subject must press the correct response key after feedback in order to advance to next trial.' + }, + show_stim_with_feedback: { + type: jsPsych.plugins.parameterType.BOOL, + default: true, + no_function: false, + description: '' + }, + show_feedback_on_timeout: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Show feedback on timeout', + default: false, + description: 'If true, stimulus will be shown during feedback. If false, only the text feedback will be displayed during feedback.' + }, + timeout_message: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Timeout message', + default: "

Please respond faster.

", + description: 'The message displayed on a timeout non-response.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show trial' + }, + feedback_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Feedback duration', + default: 2000, + description: 'How long to show feedback.' + } + } + } + + plugin.trial = function(display_element, trial) { + + display_element.innerHTML = ''; + + // hide image after time if the timing parameter is set + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('#jspsych-categorize-image-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // if prompt is set, show prompt + if (trial.prompt !== null) { + display_element.innerHTML += trial.prompt; + } + + var trial_data = {}; + + // create response function + var after_response = function(info) { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // clear keyboard listener + jsPsych.pluginAPI.cancelAllKeyboardResponses(); + + var correct = false; + if (jsPsych.pluginAPI.compareKeys(trial.key_answer, info.key)) { + correct = true; + } + + // save data + trial_data = { + rt: info.rt, + correct: correct, + stimulus: trial.stimulus, + response: info.key + }; + + display_element.innerHTML = ''; + + var timeout = info.rt == null; + doFeedback(correct, timeout); + } + + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + after_response({ + key: null, + rt: null + }); + }, trial.trial_duration); + } + + function doFeedback(correct, timeout) { + + if (timeout && !trial.show_feedback_on_timeout) { + display_element.innerHTML += trial.timeout_message; + } else { + // show image during feedback if flag is set + if (trial.show_stim_with_feedback) { + display_element.innerHTML = ''; + } + + // substitute answer in feedback string. + var atext = ""; + if (correct) { + atext = trial.correct_text.replace("%ANS%", trial.text_answer); + } else { + atext = trial.incorrect_text.replace("%ANS%", trial.text_answer); + } + + // show the feedback + display_element.innerHTML += atext; + } + // check if force correct button press is set + if (trial.force_correct_button_press && correct === false && ((timeout && trial.show_feedback_on_timeout) || !timeout)) { + + var after_forced_response = function(info) { + endTrial(); + } + + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_forced_response, + valid_responses: [trial.key_answer], + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + + } else { + jsPsych.pluginAPI.setTimeout(function() { + endTrial(); + }, trial.feedback_duration); + } + + } + + function endTrial() { + display_element.innerHTML = ''; + jsPsych.finishTrial(trial_data); + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-categorize.js b/scripts/plugins/jspsych-categorize.js deleted file mode 100644 index 17ee77b..0000000 --- a/scripts/plugins/jspsych-categorize.js +++ /dev/null @@ -1,180 +0,0 @@ -/** - * jspsych plugin for categorization trials with feedback - * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-categorize -**/ - -(function($) { - jsPsych.categorize = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['choices', 'stimuli', 'key_answer', 'text_answer', 'data']); - - var trials = []; - for (var i = 0; i < params.stimuli.length; i++) { - trials.push({}); - trials[i].type = "categorize"; - trials[i].a_path = params.stimuli[i]; - trials[i].key_answer = params.key_answer[i]; - trials[i].text_answer = (typeof params.text_answer === 'undefined') ? "" : params.text_answer[i]; - trials[i].choices = params.choices; - trials[i].correct_text = (typeof params.correct_text === 'undefined') ? "

Correct

" : params.correct_text; - trials[i].incorrect_text = (typeof params.incorrect_text === 'undefined') ? "

Incorrect

" : params.incorrect_text; - // timing params - trials[i].timing_stim = params.timing_stim || -1; // default is to show image until response - trials[i].timing_feedback_duration = params.timing_feedback_duration || 2000; - trials[i].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial; - // optional params - trials[i].show_stim_with_feedback = (typeof params.show_stim_with_feedback === 'undefined') ? true : params.show_stim_with_feedback; - trials[i].is_html = (typeof params.is_html === 'undefined') ? false : params.is_html; - trials[i].force_correct_button_press = (typeof params.force_correct_button_press === 'undefined') ? false : params.force_correct_button_press; - trials[i].prompt = (typeof params.prompt === 'undefined') ? '' : params.prompt; - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data[i]; - } - return trials; - }; - - var cat_trial_complete = false; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - switch (part) { - case 1: - // set finish flag - cat_trial_complete = false; - - if (!trial.is_html) { - // add image to display - display_element.append($('', { - "src": trial.a_path, - "class": 'jspsych-categorize-stimulus', - "id": 'jspsych-categorize-stimulus' - })); - } - else { - display_element.append($('
', { - "id": 'jspsych-categorize-stimulus', - "class": 'jspsych-categorize-stimulus', - "html": trial.a_path - })); - } - - // hide image after time if the timing parameter is set - if (trial.timing_stim > 0) { - setTimeout(function() { - if (!cat_trial_complete) { - $('#jspsych-categorize-stimulus').css('visibility', 'hidden'); - } - }, trial.timing_stim); - } - - // if prompt is set, show prompt - if (trial.prompt !== "") { - display_element.append(trial.prompt); - } - - // start measuring RT - var startTime = (new Date()).getTime(); - - // create response function - var after_response = function(info) { - - var correct = false; - if(trial.key_answer == info.key) { correct = true; } - - cat_trial_complete = true; - - // save data - var trial_data = { - "trial_type": "categorize", - "trial_index": block.trial_idx, - "rt": info.rt, - "correct": correct, - "stimulus": trial.a_path, - "key_press": info.key - }; - - block.writeData($.extend({}, trial_data, trial.data)); - - display_element.html(''); - - plugin.trial(display_element, block, trial, part + 1); - } - - jsPsych.pluginAPI.getKeyboardResponse(after_response, trial.choices, 'date', false); - - break; - - case 2: - // show image during feedback if flag is set - if (trial.show_stim_with_feedback) { - if (!trial.is_html) { - // add image to display - display_element.append($('', { - "src": trial.a_path, - "class": 'jspsych-categorize-stimulus', - "id": 'jspsych-categorize-stimulus' - })); - } - else { - display_element.append($('
', { - "id": 'jspsych-categorize-stimulus', - "class": 'jspsych-categorize-stimulus', - "html": trial.a_path - })); - } - } - - // substitute answer in feedback string. - var atext = ""; - if (block.data[block.trial_idx].correct) { - atext = trial.correct_text.replace("%ANS%", trial.text_answer); - } - else { - atext = trial.incorrect_text.replace("%ANS%", trial.text_answer); - } - - // show the feedback - display_element.append(atext); - - // check if force correct button press is set - if (trial.force_correct_button_press && block.data[block.trial_idx].correct === false) { - - var after_forced_response = function(info) { - plugin.trial(display_element, block, trial, part + 1); - } - - jsPsych.pluginAPI.getKeyboardResponse(after_forced_response, trial.key_answer, 'date', false); - - } - else { - setTimeout(function() { - plugin.trial(display_element, block, trial, part + 1); - }, trial.timing_feedback_duration); - } - break; - case 3: - display_element.html(""); - if(trial.timing_post_trial > 0){ - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } else { - block.next(); - } - break; - } - }; - - return plugin; - })(); -})(jQuery); diff --git a/scripts/plugins/jspsych-cloze.js b/scripts/plugins/jspsych-cloze.js new file mode 100644 index 0000000..2b97827 --- /dev/null +++ b/scripts/plugins/jspsych-cloze.js @@ -0,0 +1,112 @@ +/** + * jspsych-cloze + * Philipp Sprengholz + * + * Plugin for displaying a cloze test and checking participants answers against a correct solution. + * + * documentation: docs.jspsych.org + **/ + +jsPsych.plugins['cloze'] = (function () { + + var plugin = {}; + + plugin.info = { + name: 'cloze', + description: '', + parameters: { + text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Cloze text', + default: undefined, + description: 'The cloze text to be displayed. Blanks are indicated by %% signs and automatically replaced by input fields. If there is a correct answer you want the system to check against, it must be typed between the two percentage signs (i.e. %solution%).' + }, + button_text: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button text', + default: 'OK', + description: 'Text of the button participants have to press for finishing the cloze test.' + }, + check_answers: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Check answers', + default: false, + description: 'Boolean value indicating if the answers given by participants should be compared against a correct solution given in the text (between % signs) after the button was clicked.' + }, + mistake_fn: { + type: jsPsych.plugins.parameterType.FUNCTION, + pretty_name: 'Mistake function', + default: function () {}, + description: 'Function called if check_answers is set to TRUE and there is a difference between the participants answers and the correct solution provided in the text.' + } + } + }; + + plugin.trial = function (display_element, trial) { + + var html = '
'; + var elements = trial.text.split('%'); + var solutions = []; + + for (var i=0; i'; + } + } + html += '
'; + + display_element.innerHTML = html; + + var check = function() { + + var answers = []; + var answers_correct = true; + + for (var i=0; i'; + display_element.querySelector('#finish_cloze_button').addEventListener('click', check); + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-external-html.js b/scripts/plugins/jspsych-external-html.js new file mode 100644 index 0000000..3b95d87 --- /dev/null +++ b/scripts/plugins/jspsych-external-html.js @@ -0,0 +1,112 @@ +/** (July 2012, Erik Weitnauer) +The html-plugin will load and display an external html pages. To proceed to the next, the +user might either press a button on the page or a specific key. Afterwards, the page get hidden and +the plugin will wait of a specified time before it proceeds. + +documentation: docs.jspsych.org +*/ + +jsPsych.plugins['external-html'] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'external-html', + description: '', + parameters: { + url: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'URL', + default: undefined, + description: 'The url of the external html page' + }, + cont_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Continue key', + default: null, + description: 'The key to continue to the next page.' + }, + cont_btn: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Continue button', + default: null, + description: 'The button to continue to the next page.' + }, + check_fn: { + type: jsPsych.plugins.parameterType.FUNCTION, + pretty_name: 'Check function', + default: function() { return true; }, + description: '' + }, + force_refresh: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Force refresh', + default: false, + description: 'Refresh page.' + }, + // if execute_Script == true, then all javascript code on the external page + // will be executed in the plugin site within your jsPsych test + execute_script: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Execute scripts', + default: false, + description: 'If true, JS scripts on the external html file will be executed.' + } + } + } + + plugin.trial = function(display_element, trial) { + + var url = trial.url; + if (trial.force_refresh) { + url = trial.url + "?t=" + performance.now(); + } + + load(display_element, url, function() { + var t0 = performance.now(); + var finish = function() { + if (trial.check_fn && !trial.check_fn(display_element)) { return }; + if (trial.cont_key) { display_element.removeEventListener('keydown', key_listener); } + var trial_data = { + rt: performance.now() - t0, + url: trial.url + }; + display_element.innerHTML = ''; + jsPsych.finishTrial(trial_data); + }; + + // by default, scripts on the external page are not executed with XMLHttpRequest(). + // To activate their content through DOM manipulation, we need to relocate all script tags + if (trial.execute_script) { + for (const scriptElement of display_element.getElementsByTagName("script")) { + const relocatedScript = document.createElement("script"); + relocatedScript.text = scriptElement.text; + scriptElement.parentNode.replaceChild(relocatedScript, scriptElement); + }; + } + + if (trial.cont_btn) { display_element.querySelector('#'+trial.cont_btn).addEventListener('click', finish); } + if (trial.cont_key) { + var key_listener = function(e) { + if (jsPsych.pluginAPI.compareKeys(e.key,trial.cont_key)) finish(); + }; + display_element.addEventListener('keydown', key_listener); + } + }); + }; + + // helper to load via XMLHttpRequest + function load(element, file, callback){ + var xmlhttp = new XMLHttpRequest(); + xmlhttp.open("GET", file, true); + xmlhttp.onload = function(){ + if(xmlhttp.status == 200 || xmlhttp.status == 0){ //Check if loaded + element.innerHTML = xmlhttp.responseText; + callback(); + } + } + xmlhttp.send(); + } + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-free-sort.js b/scripts/plugins/jspsych-free-sort.js index 727f503..76ca8b0 100644 --- a/scripts/plugins/jspsych-free-sort.js +++ b/scripts/plugins/jspsych-free-sort.js @@ -2,156 +2,477 @@ * jspsych-free-sort * plugin for drag-and-drop sorting of a collection of images * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-free-sort + * + * documentation: docs.jspsych.org */ -(function($) { - jsPsych['free-sort'] = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['data']); - - var trials = new Array(params.stimuli.length); - for (var i = 0; i < trials.length; i++) { - trials[i] = { - "type": "free-sort", - "images": params.stimuli[i], // array of images to display - "stim_height": params.stim_height || 100, - "stim_width": params.stim_width || 100, - "timing_post_trial": (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial, - "prompt": (typeof params.prompt === 'undefined') ? '' : params.prompt, - "prompt_location": params.prompt_location || "above", - "sort_area_width": params.sort_area_width || 800, - "sort_area_height": params.sort_area_height || 800, - "data": (typeof params.data === 'undefined') ? {} : params.data[i] - }; - } - return trials; - }; - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); +jsPsych.plugins['free-sort'] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('free-sort', 'stimuli', 'image'); + + plugin.info = { + name: 'free-sort', + description: '', + parameters: { + stimuli: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Stimuli', + default: undefined, + array: true, + description: 'items to be displayed.' + }, + stim_height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus height', + default: 100, + description: 'Height of items in pixels.' + }, + stim_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus width', + default: 100, + description: 'Width of items in pixels' + }, + scale_factor: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Stimulus scaling factor', + default: 1.5, + description: 'How much larger to make the stimulus while moving (1 = no scaling)' + }, + sort_area_height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Sort area height', + default: 700, + description: 'The height in pixels of the container that subjects can move the stimuli in.' + }, + sort_area_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Sort area width', + default: 700, + description: 'The width in pixels of the container that subjects can move the stimuli in.' + }, + sort_area_shape: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Sort area shape', + options: ['square','ellipse'], + default: 'ellipse', + description: 'The shape of the sorting area' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: '', + description: 'It can be used to provide a reminder about the action the subject is supposed to take.' + }, + prompt_location: { + type: jsPsych.plugins.parameterType.SELECT, + pretty_name: 'Prompt location', + options: ['above','below'], + default: 'above', + description: 'Indicates whether to show prompt "above" or "below" the sorting area.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + description: 'The text that appears on the button to continue to the next trial.' + }, + change_border_background_color: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Change border background color', + default: true, + description: 'If true, the sort area border color will change while items are being moved in and out of '+ + 'the sort area, and the background color will change once all items have been moved into the '+ + 'sort area. If false, the border will remain black and the background will remain white throughout the trial.' + }, + border_color_in: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Border color - in', + default: '#a1d99b', + description: 'If change_border_background_color is true, the sort area border will change to this color '+ + 'when an item is being moved into the sort area, and the background will change to this color '+ + 'when all of the items have been moved into the sort area.' + }, + border_color_out: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Border color - out', + default: '#fc9272', + description: 'If change_border_background_color is true, this will be the color of the sort area border '+ + 'when there are one or more items that still need to be moved into the sort area.' + }, + border_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Border width', + default: null, + description: 'The width in pixels of the border around the sort area. If null, the border width '+ + 'defaults to 3% of the sort area height.' + }, + counter_text_unfinished: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Counter text unfinished', + default: 'You still need to place %n% item%s% inside the sort area.', + description: 'Text to display when there are one or more items that still need to be placed in the sort area. '+ + 'If "%n%" is included in the string, it will be replaced with the number of items that still need to be moved inside. '+ + 'If "%s%" is included in the string, a "s" will be included when the number of items remaining is greater than one.' + }, + counter_text_finished: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Counter text finished', + default: 'All items placed. Feel free to reposition items if necessary.', + description: 'Text that will take the place of the counter_text_unfinished text when all items have been moved inside the sort area.' + }, + stim_starts_inside: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Stim starts inside', + default: false, + description: 'If false, the images will be positioned to the left and right of the sort area when the trial loads. '+ + 'If true, the images will be positioned at random locations inside the sort area when the trial loads.' + }, + column_spread_factor: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'column spread factor', + default: 1, + description: 'When the images appear outside the sort area, this determines the x-axis spread of the image columns. '+ + 'Default value is 1. Values less than 1 will compress the image columns along the x-axis, and values greater than 1 will spread them farther apart.' + }, + } + } + + plugin.trial = function(display_element, trial) { + + var start_time = performance.now(); + + if (trial.change_border_background_color == false) { + trial.border_color_out = "#000000"; + } + + if (trial.border_width == null) { + trial.border_width = trial.sort_area_height*.03; + } + + let html = + '
'; + + // another div for border + html += '
'+get_counter_text(trial.stimuli.length)+'

'; + + // position prompt above or below + if (trial.prompt_location == "below") { + html += html_text + } else { + html = html_text + html + } + // add button + html += '
'; + + display_element.innerHTML = html; + + // store initial location data + let init_locations = []; + + if (!trial.stim_starts_inside) { + // determine number of rows and colums, must be a even number + let num_rows = Math.ceil(Math.sqrt(trial.stimuli.length)) + if ( num_rows % 2 != 0) { + num_rows = num_rows + 1 + } + + // compute coords for left and right side of arena + var r_coords = []; + var l_coords = []; + for (const x of make_arr(0, trial.sort_area_width - trial.stim_width, num_rows) ) { + for (const y of make_arr(0, trial.sort_area_height - trial.stim_height, num_rows) ) { + if ( x > ( (trial.sort_area_width - trial.stim_width) * .5 ) ) { + //r_coords.push({ x:x, y:y } ) + r_coords.push({ x:x + (trial.sort_area_width) * (.5*trial.column_spread_factor) , y:y }); + } else { + l_coords.push({ x:x - (trial.sort_area_width) * (.5*trial.column_spread_factor) , y:y }); + //l_coords.push({ x:x, y:y } ) + } + } + } + + // repeat coordinates until you have enough coords (may be obsolete) + while ( ( r_coords.length + l_coords.length ) < trial.stimuli.length ) { + r_coords = r_coords.concat(r_coords) + l_coords = l_coords.concat(l_coords) + } + // reverse left coords, so that coords closest to arena is used first + l_coords = l_coords.reverse() + + // shuffle stimuli, so that starting positions are random + trial.stimuli = shuffle(trial.stimuli); + } + + let inside = [] + for (let i = 0; i < trial.stimuli.length; i++) { + var coords; + if (trial.stim_starts_inside) { + coords = random_coordinate(trial.sort_area_width - trial.stim_width, trial.sort_area_height - trial.stim_height); + } else { + if ( (i % 2) == 0 ) { + coords = r_coords[Math.floor(i * .5)]; + } else { + coords = l_coords[Math.floor(i * .5)]; + } + } + + display_element.querySelector("#jspsych-free-sort-arena").innerHTML += ''+ + ''; + + init_locations.push({ + src: trial.stimuli[i], + x: coords.x, + y: coords.y + }); + if (trial.stim_starts_inside) { + inside.push(true); + } else { + inside.push(false); + } + } + + // moves within a trial + let moves = []; + + // are objects currently inside + let cur_in = false + + // draggable items + const draggables = display_element.querySelectorAll('.jspsych-free-sort-draggable'); + + // button (will show when all items are inside) and border (will change color) + const border = display_element.querySelector("#jspsych-free-sort-border") + const button = display_element.querySelector('#jspsych-free-sort-done-btn') + + // when trial starts, modify text and border/background if all items are inside (stim_starts_inside: true) + if (inside.some(Boolean) && trial.change_border_background_color) { + border.style.borderColor = trial.border_color_in; + } + if (inside.every(Boolean)) { + if (trial.change_border_background_color) { + border.style.background = trial.border_color_in; + } + button.style.visibility = "visible"; + display_element.querySelector("#jspsych-free-sort-counter").innerHTML = trial.counter_text_finished; + } + + let start_event_name = 'mousedown'; + let move_event_name = 'mousemove'; + let end_event_name = 'mouseup'; + if (typeof document.ontouchend !== 'undefined'){ // for touch devices + start_event_name = 'touchstart' + move_event_name = 'touchmove' + end_event_name = 'touchend' + } + + for(let i=0; i', { - "id": "jspsych-free-sort-arena", - "class": "jspsych-free-sort-arena", - "css": { - "position": "relative", - "width": trial.sort_area_width, - "height": trial.sort_area_height - } - })); - - // check if prompt exists and if it is shown below - if (trial.prompt && trial.prompt_location == "below") { - display_element.append(trial.prompt); + // modify text and background if all items are inside + if (inside.every(Boolean)) { + if (trial.change_border_background_color) { + border.style.background = trial.border_color_in; } + button.style.visibility = "visible"; + display_element.querySelector("#jspsych-free-sort-counter").innerHTML = trial.counter_text_finished; + } else { + border.style.background = "none"; + button.style.visibility = "hidden"; + display_element.querySelector("#jspsych-free-sort-counter").innerHTML = get_counter_text(inside.length - inside.filter(Boolean).length); + } + } + document.addEventListener(move_event_name, move_event); - // store initial location data - var init_locations = []; - - for (var i = 0; i < trial.images.length; i++) { - var coords = random_coordinate(trial.sort_area_width - trial.stim_width, trial.sort_area_height - trial.stim_height); - - $("#jspsych-free-sort-arena").append($('', { - "src": trial.images[i], - "class": "jspsych-free-sort-draggable", - "css": { - "position": "absolute", - "top": coords.y, - "left": coords.x - } - })); - - init_locations.push({ - "src": trial.images[i], - "x": coords.x, - "y": coords.y - }); + var end_event = function(e){ + document.removeEventListener(move_event_name, move_event); + elem.style.transform = "scale(1, 1)"; + if (trial.change_border_background_color) { + if (inside.every(Boolean)) { + border.style.background = trial.border_color_in; + border.style.borderColor = trial.border_color_in; + } else { + border.style.background = "none"; + border.style.borderColor = trial.border_color_out; } + } + moves.push({ + src: elem.dataset.src, + x: elem.offsetLeft, + y: elem.offsetTop + }); + document.removeEventListener(end_event_name, end_event); + } + document.addEventListener(end_event_name, end_event); + }); + } - var moves = []; - - $('.jspsych-free-sort-draggable').draggable({ - containment: "#jspsych-free-sort-arena", - scroll: false, - stack: ".jspsych-free-sort-draggable", - stop: function(event, ui) { - moves.push({ - "src": event.target.src.split("/").slice(-1)[0], - "x": ui.position.left, - "y": ui.position.top - }); - } - }); - - display_element.append($(''; + var listener = display_element.querySelector('#jspsych-fullscreen-btn').addEventListener('click', function() { + var element = document.documentElement; + if (element.requestFullscreen) { + element.requestFullscreen(); + } else if (element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if (element.webkitRequestFullscreen) { + element.webkitRequestFullscreen(); + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } + endTrial(); + }); + } else { + if ( document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement ) { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } + } + endTrial(); + } + } + + function endTrial() { + + display_element.innerHTML = ''; + + jsPsych.pluginAPI.setTimeout(function(){ + + var trial_data = { + success: !keyboardNotAllowed + }; + + jsPsych.finishTrial(trial_data); + + }, trial.delay_after); + + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-html-button-response.js b/scripts/plugins/jspsych-html-button-response.js new file mode 100644 index 0000000..28f6ecb --- /dev/null +++ b/scripts/plugins/jspsych-html-button-response.js @@ -0,0 +1,188 @@ +/** + * jspsych-html-button-response + * Josh de Leeuw + * + * plugin for displaying a stimulus and getting a button response + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["html-button-response"] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'html-button-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Stimulus', + default: undefined, + description: 'The HTML string to be displayed' + }, + choices: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Choices', + default: undefined, + array: true, + description: 'The labels for the buttons.' + }, + button_html: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button HTML', + default: '', + array: true, + description: 'The html of the button. Can create own style.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed under the button.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + margin_vertical: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin vertical', + default: '0px', + description: 'The vertical margin of the button.' + }, + margin_horizontal: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin horizontal', + default: '8px', + description: 'The horizontal margin of the button.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, then trial will end when user responds.' + }, + } + } + + plugin.trial = function(display_element, trial) { + + // display stimulus + var html = '
'+trial.stimulus+'
'; + + //display buttons + var buttons = []; + if (Array.isArray(trial.button_html)) { + if (trial.button_html.length == trial.choices.length) { + buttons = trial.button_html; + } else { + console.error('Error in html-button-response plugin. The length of the button_html array does not equal the length of the choices array'); + } + } else { + for (var i = 0; i < trial.choices.length; i++) { + buttons.push(trial.button_html); + } + } + html += '
'; + for (var i = 0; i < trial.choices.length; i++) { + var str = buttons[i].replace(/%choice%/g, trial.choices[i]); + html += '
'+str+'
'; + } + html += '
'; + + //show prompt if there is one + if (trial.prompt !== null) { + html += trial.prompt; + } + display_element.innerHTML = html; + + // start time + var start_time = performance.now(); + + // add event listeners to buttons + for (var i = 0; i < trial.choices.length; i++) { + display_element.querySelector('#jspsych-html-button-response-button-' + i).addEventListener('click', function(e){ + var choice = e.currentTarget.getAttribute('data-choice'); // don't use dataset for jsdom compatibility + after_response(choice); + }); + } + + // store response + var response = { + rt: null, + button: null + }; + + // function to handle responses by the subject + function after_response(choice) { + + // measure rt + var end_time = performance.now(); + var rt = end_time - start_time; + response.button = parseInt(choice); + response.rt = rt; + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-html-button-response-stimulus').className += ' responded'; + + // disable all the buttons after a response + var btns = document.querySelectorAll('.jspsych-html-button-response-button button'); + for(var i=0; i'; + + // add prompt + if(trial.prompt !== null){ + new_html += trial.prompt; + } + + // draw + display_element.innerHTML = new_html; + + // store response + var response = { + rt: null, + key: null + }; + + // function to end trial when it is time + var end_trial = function() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // kill keyboard listeners + if (typeof keyboardListener !== 'undefined') { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener); + } + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.key + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + // function to handle responses by the subject + var after_response = function(info) { + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-html-keyboard-response-stimulus').className += ' responded'; + + // only record the first response + if (response.key == null) { + response = info; + } + + if (trial.response_ends_trial) { + end_trial(); + } + }; + + // start the response listener + if (trial.choices != jsPsych.NO_KEYS) { + var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + + // hide stimulus if stimulus_duration is set + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('#jspsych-html-keyboard-response-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-html-slider-response.js b/scripts/plugins/jspsych-html-slider-response.js new file mode 100644 index 0000000..f760084 --- /dev/null +++ b/scripts/plugins/jspsych-html-slider-response.js @@ -0,0 +1,202 @@ +/** + * jspsych-html-slider-response + * a jspsych plugin for free response survey questions + * + * Josh de Leeuw + * + * documentation: docs.jspsych.org + * + */ + + +jsPsych.plugins['html-slider-response'] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'html-slider-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Stimulus', + default: undefined, + description: 'The HTML string to be displayed' + }, + min: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Min slider', + default: 0, + description: 'Sets the minimum value of the slider.' + }, + max: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Max slider', + default: 100, + description: 'Sets the maximum value of the slider', + }, + slider_start: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Slider starting value', + default: 50, + description: 'Sets the starting value of the slider', + }, + step: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Step', + default: 1, + description: 'Sets the step of the slider' + }, + labels: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name:'Labels', + default: [], + array: true, + description: 'Labels of the slider.', + }, + slider_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name:'Slider width', + default: null, + description: 'Width of the slider in pixels.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + array: false, + description: 'Label of the button to advance.' + }, + require_movement: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Require movement', + default: false, + description: 'If true, the participant will have to move the slider before continuing.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the slider.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, trial will end when user makes a response.' + }, + } + } + + plugin.trial = function(display_element, trial) { + + // half of the thumb width value from jspsych.css, used to adjust the label positions + var half_thumb_width = 7.5; + + var html = '
'; + html += '
' + trial.stimulus + '
'; + html += '
'; + html += ''; + html += '
' + for(var j=0; j < trial.labels.length; j++){ + var label_width_perc = 100/(trial.labels.length-1); + var percent_of_range = j * (100/(trial.labels.length - 1)); + var percent_dist_from_center = ((percent_of_range-50)/50)*100; + var offset = (percent_dist_from_center * half_thumb_width)/100; + html += '
'; + html += ''+trial.labels[j]+''; + html += '
' + } + html += '
'; + html += '
'; + html += '
'; + + if (trial.prompt !== null){ + html += trial.prompt; + } + + // add submit button + html += ''; + + display_element.innerHTML = html; + + var response = { + rt: null, + response: null + }; + + if(trial.require_movement){ + display_element.querySelector('#jspsych-html-slider-response-response').addEventListener('click', function(){ + display_element.querySelector('#jspsych-html-slider-response-next').disabled = false; + }); + } + + display_element.querySelector('#jspsych-html-slider-response-next').addEventListener('click', function() { + // measure response time + var endTime = performance.now(); + response.rt = endTime - startTime; + response.response = display_element.querySelector('#jspsych-html-slider-response-response').valueAsNumber; + + if(trial.response_ends_trial){ + end_trial(); + } else { + display_element.querySelector('#jspsych-html-slider-response-next').disabled = true; + } + + }); + + function end_trial(){ + + jsPsych.pluginAPI.clearAllTimeouts(); + + // save data + var trialdata = { + rt: response.rt, + stimulus: trial.stimulus, + slider_start: trial.slider_start, + response: response.response + }; + + display_element.innerHTML = ''; + + // next trial + jsPsych.finishTrial(trialdata); + } + + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('#jspsych-html-slider-response-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } + + var startTime = performance.now(); + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-html.js b/scripts/plugins/jspsych-html.js deleted file mode 100644 index 97a554c..0000000 --- a/scripts/plugins/jspsych-html.js +++ /dev/null @@ -1,83 +0,0 @@ -/** (July 2012, Erik Weitnauer) -The html-plugin will load and display an arbitrary number of html pages. To proceed to the next, the -user might either press a button on the page or a specific key. Afterwards, the page get hidden and -the plugin will wait of a specified time before it proceeds. - -documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-html -*/ -(function($) { - jsPsych.html = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['pages']); - - var trials = []; - - - for (var i = 0; i < params.pages.length; i++) { - trials.push({ - type: "html", - url: params.pages[i].url, - cont_key: params.pages[i].cont_key || params.cont_key, - cont_btn: params.pages[i].cont_btn || params.cont_btn, - timing_post_trial: params.pages[i].timing_post_trial || (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial, - check_fn: params.pages[i].check_fn, - force_refresh: (typeof params.force_refresh === 'undefined') ? false : params.force_refresh - }); - } - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial, ["check_fn"]); - - var url = trial.url; - if (trial.force_refresh) { - url = trial.url + "?time=" + (new Date().getTime()); - } - - display_element.load(trial.url, function() { - var t0 = (new Date()).getTime(); - var finish = function() { - if (trial.check_fn && !trial.check_fn(display_element)) return; - if (trial.cont_key) $(document).unbind('keydown', key_listener); - block.writeData({ - trial_type: "html", - trial_index: block.trial_idx, - rt: (new Date()).getTime() - t0, - url: trial.url - }); - if (trial.timing_post_trial > 0) { - // hide display_element, since it could have a border and we want a blank screen during timing - display_element.hide(); - setTimeout(function() { - display_element.empty(); - display_element.show(); - block.next(); - }, trial.timing); - } - else { - display_element.empty(); - block.next(); - } - }; - if (trial.cont_btn) $('#' + trial.cont_btn).click(finish); - if (trial.cont_key) { - var key_listener = function(e) { - if (e.which == trial.cont_key) finish(); - }; - $(document).keydown(key_listener); - } - }); - }; - - return plugin; - })(); -})(jQuery); diff --git a/scripts/plugins/jspsych-iat-html.js b/scripts/plugins/jspsych-iat-html.js new file mode 100644 index 0000000..d9efe00 --- /dev/null +++ b/scripts/plugins/jspsych-iat-html.js @@ -0,0 +1,284 @@ +/** + * jspsych-iat + * Kristin Diep + * + * plugin for running an IAT (Implicit Association Test) with an HTML-formatted stimulus + * + * documentation: docs.jspsych.org + * + **/ + + + jsPsych.plugins['iat-html'] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'iat-html', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Stimulus', + default: undefined, + description: 'The HTML string to be displayed.' + }, + left_category_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Left category key', + default: 'e', + description: 'Key press that is associated with the left category label.' + }, + right_category_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Right category key', + default: 'i', + description: 'Key press that is associated with the right category label.' + }, + left_category_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Left category label', + array: true, + default: ['left'], + description: 'The label that is associated with the stimulus. Aligned to the left side of page.' + }, + right_category_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Right category label', + array: true, + default: ['right'], + description: 'The label that is associated with the stimulus. Aligned to the right side of the page.' + }, + key_to_move_forward: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key to move forward', + array: true, + default: jsPsych.ALL_KEYS, + description: 'The keys that allow the user to advance to the next trial if their key press was incorrect.' + }, + display_feedback: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Display feedback', + default: false, + description: 'If true, then html when wrong will be displayed when user makes an incorrect key press.' + }, + html_when_wrong: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'HTML when wrong', + default: 'X', + description: 'The HTML to display when a user presses the wrong key.' + }, + bottom_instructions: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Bottom instructions', + default: '

If you press the wrong key, a red X will appear. Press any key to continue.

', + description: 'Instructions shown at the bottom of the page.' + }, + force_correct_key_press: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Force correct key press', + default: false, + description: 'If true, in order to advance to the next trial after a wrong key press the user will be forced to press the correct key.' + }, + stim_key_association: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Stimulus key association', + options: ['left', 'right'], + default: undefined, + description: 'Stimulus will be associated with either "left" or "right".' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, trial will end when user makes a response.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + } + } + + + plugin.trial = function(display_element, trial) { + + var html_str = ""; + + html_str += "

" + trial.stimulus + "

"; + + html_str += "
"; + + if(trial.left_category_label.length == 1) { + html_str += "

Press " + trial.left_category_key + " for:
" + + trial.left_category_label[0].bold() + "

"; + } else { + html_str += "

Press " + trial.left_category_key + " for:
" + + trial.left_category_label[0].bold() + "
" + "or
" + + trial.left_category_label[1].bold() + "

"; + } + + html_str += "
"; + + if(trial.right_category_label.length == 1) { + html_str += "

Press " + trial.right_category_key + " for:
" + + trial.right_category_label[0].bold() + '

'; + } else { + html_str += "

Press " + trial.right_category_key + " for:
" + + trial.right_category_label[0].bold() + "
" + "or
" + + trial.right_category_label[1].bold() + "

"; + } + + html_str += "
"; + + if(trial.display_feedback === true) { + html_str += ""; + html_str += "
"+trial.bottom_instructions+"
"; + } else { + html_str += "
"+trial.bottom_instructions+"
"; + } + + html_str += "
"; + + display_element.innerHTML = html_str; + + + // store response + var response = { + rt: null, + key: null, + correct: false + }; + + // function to end trial when it is time + var end_trial = function() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // kill keyboard listeners + if (typeof keyboardListener !== 'undefined') { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener); + } + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.key, + correct: response.correct + }; + + // clears the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + var leftKeyCode = trial.left_category_key; + var rightKeyCode = trial.right_category_key; + + // function to handle responses by the subject + var after_response = function(info) { + var wImg = document.getElementById("wrongImgContainer"); + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-iat-stim').className += ' responded'; + + // only record the first response + if (response.key == null ) { + response = info; + } + + if(trial.stim_key_association == "right") { + if(response.rt !== null && jsPsych.pluginAPI.compareKeys(response.key, rightKeyCode)) { + response.correct = true; + if (trial.response_ends_trial) { + end_trial(); + } + } else { + response.correct = false; + if(!trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + } + if (trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + if(trial.force_correct_key_press) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [trial.right_category_key] + }); + } else { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: trial.key_to_move_forward + });} + } else if(trial.response_ends_trial && trial.display_feedback != true) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [jsPsych.ALL_KEYS] + }); + } else if(!trial.response_ends_trial && trial.display_feedback != true) { + + } + } + } else if(trial.stim_key_association == "left") { + if(response.rt !== null && jsPsych.pluginAPI.compareKeys(response.key, leftKeyCode)) { + response.correct = true; + if (trial.response_ends_trial) { + end_trial(); + } + } else { + response.correct = false; + if(!trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + } + if (trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + if(trial.force_correct_key_press) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [trial.left_category_key] + }); + } else { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: trial.key_to_move_forward + });} + } else if(trial.response_ends_trial && trial.display_feedback != true) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [jsPsych.ALL_KEYS] + }); + } else if(!trial.response_ends_trial && trial.display_feedback != true) { + + } + } + } + }; + + // start the response listener + if (trial.left_category_key != jsPsych.NO_KEYS && trial.right_category_key != jsPsych.NO_KEYS) { + var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: [trial.left_category_key, trial.right_category_key], + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + + // end trial if time limit is set + if (trial.trial_duration !== null && trial.response_ends_trial != true) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-iat-image.js b/scripts/plugins/jspsych-iat-image.js new file mode 100644 index 0000000..d21b8c5 --- /dev/null +++ b/scripts/plugins/jspsych-iat-image.js @@ -0,0 +1,286 @@ +/** + * jspsych-iat + * Kristin Diep + * + * plugin for running an IAT (Implicit Association Test) with an image stimulus + * + * documentation: docs.jspsych.org + * + **/ + + + jsPsych.plugins['iat-image'] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('iat-image', 'stimulus', 'image'); + + plugin.info = { + name: 'iat-image', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimulus', + default: undefined, + description: 'The image to be displayed' + }, + left_category_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Left category key', + default: 'e', + description: 'Key press that is associated with the left category label.' + }, + right_category_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Right category key', + default: 'i', + description: 'Key press that is associated with the right category label.' + }, + left_category_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Left category label', + array: true, + default: ['left'], + description: 'The label that is associated with the stimulus. Aligned to the left side of page.' + }, + right_category_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Right category label', + array: true, + default: ['right'], + description: 'The label that is associated with the stimulus. Aligned to the right side of the page.' + }, + key_to_move_forward: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key to move forward', + array: true, + default: jsPsych.ALL_KEYS, + description: 'The keys that allow the user to advance to the next trial if their key press was incorrect.' + }, + display_feedback: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Display feedback', + default: false, + description: 'If true, then html when wrong will be displayed when user makes an incorrect key press.' + }, + html_when_wrong: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'HTML when wrong', + default: 'X', + description: 'The HTML to display when a user presses the wrong key.' + }, + bottom_instructions: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Bottom instructions', + default: '

If you press the wrong key, a red X will appear. Press any key to continue.

', + description: 'Instructions shown at the bottom of the page.' + }, + force_correct_key_press: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Force correct key press', + default: false, + description: 'If true, in order to advance to the next trial after a wrong key press the user will be forced to press the correct key.' + }, + stim_key_association: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Stimulus key association', + options: ['left', 'right'], + default: undefined, + description: 'Stimulus will be associated with either "left" or "right".' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, trial will end when user makes a response.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + } + } + + + plugin.trial = function(display_element, trial) { + + var html_str = ""; + + html_str += "
"; + + html_str += "
"; + + if(trial.left_category_label.length == 1) { + html_str += "

Press " + trial.left_category_key + " for:
" + + trial.left_category_label[0].bold() + "

"; + } else { + html_str += "

Press " + trial.left_category_key + " for:
" + + trial.left_category_label[0].bold() + "
" + "or
" + + trial.left_category_label[1].bold() + "

"; + } + + html_str += "
"; + + if(trial.right_category_label.length == 1) { + html_str += "

Press " + trial.right_category_key + " for:
" + + trial.right_category_label[0].bold() + '

'; + } else { + html_str += "

Press " + trial.right_category_key + " for:
" + + trial.right_category_label[0].bold() + "
" + "or
" + + trial.right_category_label[1].bold() + "

"; + } + + html_str += "
"; + + if(trial.display_feedback === true) { + html_str += ""; + html_str += "
"+trial.bottom_instructions+"
"; + } else { + html_str += "
"+trial.bottom_instructions+"
"; + } + + html_str += "
"; + + display_element.innerHTML = html_str; + + + // store response + var response = { + rt: null, + key: null, + correct: false + }; + + // function to end trial when it is time + var end_trial = function() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // kill keyboard listeners + if (typeof keyboardListener !== 'undefined') { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener); + } + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.key, + correct: response.correct + }; + + // clears the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + var leftKeyCode = trial.left_category_key; + var rightKeyCode = trial.right_category_key; + + // function to handle responses by the subject + var after_response = function(info) { + var wImg = document.getElementById("wrongImgContainer"); + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-iat-stim').className += ' responded'; + + // only record the first response + if (response.key == null ) { + response = info; + } + + if(trial.stim_key_association == "right") { + if(response.rt !== null && jsPsych.pluginAPI.compareKeys(response.key, rightKeyCode)) { + response.correct = true; + if (trial.response_ends_trial) { + end_trial(); + } + } else { + response.correct = false; + if(!trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + } + if (trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + if(trial.force_correct_key_press) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [trial.right_category_key] + }); + } else { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: trial.key_to_move_forward + });} + } else if(trial.response_ends_trial && trial.display_feedback != true) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [jsPsych.ALL_KEYS] + }); + } else if(!trial.response_ends_trial && trial.display_feedback != true) { + + } + } + } else if(trial.stim_key_association == "left") { + if(response.rt !== null && jsPsych.pluginAPI.compareKeys(response.key, leftKeyCode)) { + response.correct = true; + if (trial.response_ends_trial) { + end_trial(); + } + } else { + response.correct = false; + if(!trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + } + if (trial.response_ends_trial && trial.display_feedback == true) { + wImg.style.visibility = "visible"; + if(trial.force_correct_key_press) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [trial.left_category_key] + }); + } else { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: trial.key_to_move_forward + });} + } else if(trial.response_ends_trial && trial.display_feedback != true) { + var keyListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: end_trial, + valid_responses: [jsPsych.ALL_KEYS] + }); + } else if(!trial.response_ends_trial && trial.display_feedback != true) { + + } + } + } + }; + + // start the response listener + if (trial.left_category_key != jsPsych.NO_KEYS && trial.right_category_key != jsPsych.NO_KEYS) { + var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: [trial.left_category_key, trial.right_category_key], + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + + // end trial if time limit is set + if (trial.trial_duration !== null && trial.response_ends_trial != true) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-image-button-response.js b/scripts/plugins/jspsych-image-button-response.js new file mode 100644 index 0000000..b19341a --- /dev/null +++ b/scripts/plugins/jspsych-image-button-response.js @@ -0,0 +1,327 @@ +/** + * jspsych-image-button-response + * Josh de Leeuw + * + * plugin for displaying a stimulus and getting a button response + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["image-button-response"] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('image-button-response', 'stimulus', 'image'); + + plugin.info = { + name: 'image-button-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimulus', + default: undefined, + description: 'The image to be displayed' + }, + stimulus_height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Image height', + default: null, + description: 'Set the image height in pixels' + }, + stimulus_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Image width', + default: null, + description: 'Set the image width in pixels' + }, + maintain_aspect_ratio: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Maintain aspect ratio', + default: true, + description: 'Maintain the aspect ratio after setting width or height' + }, + choices: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Choices', + default: undefined, + array: true, + description: 'The labels for the buttons.' + }, + button_html: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button HTML', + default: '', + array: true, + description: 'The html of the button. Can create own style.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed under the button.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + margin_vertical: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin vertical', + default: '0px', + description: 'The vertical margin of the button.' + }, + margin_horizontal: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin horizontal', + default: '8px', + description: 'The horizontal margin of the button.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, then trial will end when user responds.' + }, + render_on_canvas: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Render on canvas', + default: true, + description: 'If true, the image will be drawn onto a canvas element (prevents blank screen between consecutive images in some browsers).'+ + 'If false, the image will be shown via an img element.' + } + } + } + + plugin.trial = function(display_element, trial) { + + var height, width; + var html; + if (trial.render_on_canvas) { + var image_drawn = false; + // first clear the display element (because the render_on_canvas method appends to display_element instead of overwriting it with .innerHTML) + if (display_element.hasChildNodes()) { + // can't loop through child list because the list will be modified by .removeChild() + while (display_element.firstChild) { + display_element.removeChild(display_element.firstChild); + } + } + // create canvas element and image + var canvas = document.createElement("canvas"); + canvas.id = "jspsych-image-button-response-stimulus"; + canvas.style.margin = 0; + canvas.style.padding = 0; + var ctx = canvas.getContext("2d"); + var img = new Image(); + img.onload = function() { + // if image wasn't preloaded, then it will need to be drawn whenever it finishes loading + if (!image_drawn) { + getHeightWidth(); // only possible to get width/height after image loads + ctx.drawImage(img,0,0,width,height); + } + }; + img.src = trial.stimulus; + // get/set image height and width - this can only be done after image loads because uses image's naturalWidth/naturalHeight properties + function getHeightWidth() { + if (trial.stimulus_height !== null) { + height = trial.stimulus_height; + if (trial.stimulus_width == null && trial.maintain_aspect_ratio) { + width = img.naturalWidth * (trial.stimulus_height/img.naturalHeight); + } + } else { + height = img.naturalHeight; + } + if (trial.stimulus_width !== null) { + width = trial.stimulus_width; + if (trial.stimulus_height == null && trial.maintain_aspect_ratio) { + height = img.naturalHeight * (trial.stimulus_width/img.naturalWidth); + } + } else if (!(trial.stimulus_height !== null & trial.maintain_aspect_ratio)) { + // if stimulus width is null, only use the image's natural width if the width value wasn't set + // in the if statement above, based on a specified height and maintain_aspect_ratio = true + width = img.naturalWidth; + } + canvas.height = height; + canvas.width = width; + } + getHeightWidth(); // call now, in case image loads immediately (is cached) + // create buttons + var buttons = []; + if (Array.isArray(trial.button_html)) { + if (trial.button_html.length == trial.choices.length) { + buttons = trial.button_html; + } else { + console.error('Error in image-button-response plugin. The length of the button_html array does not equal the length of the choices array'); + } + } else { + for (var i = 0; i < trial.choices.length; i++) { + buttons.push(trial.button_html); + } + } + var btngroup_div = document.createElement('div'); + btngroup_div.id = "jspsych-image-button-response-btngroup"; + html = ''; + for (var i = 0; i < trial.choices.length; i++) { + var str = buttons[i].replace(/%choice%/g, trial.choices[i]); + html += '
'+str+'
'; + } + btngroup_div.innerHTML = html; + // add canvas to screen and draw image + display_element.insertBefore(canvas, null); + if (img.complete && Number.isFinite(width) && Number.isFinite(height)) { + // if image has loaded and width/height have been set, then draw it now + // (don't rely on img onload function to draw image when image is in the cache, because that causes a delay in the image presentation) + ctx.drawImage(img,0,0,width,height); + image_drawn = true; + } + // add buttons to screen + display_element.insertBefore(btngroup_div, canvas.nextElementSibling); + // add prompt if there is one + if (trial.prompt !== null) { + display_element.insertAdjacentHTML('beforeend', trial.prompt); + } + + } else { + + // display stimulus as an image element + html = ''; + //display buttons + var buttons = []; + if (Array.isArray(trial.button_html)) { + if (trial.button_html.length == trial.choices.length) { + buttons = trial.button_html; + } else { + console.error('Error in image-button-response plugin. The length of the button_html array does not equal the length of the choices array'); + } + } else { + for (var i = 0; i < trial.choices.length; i++) { + buttons.push(trial.button_html); + } + } + html += '
'; + + for (var i = 0; i < trial.choices.length; i++) { + var str = buttons[i].replace(/%choice%/g, trial.choices[i]); + html += '
'+str+'
'; + } + html += '
'; + // add prompt + if (trial.prompt !== null){ + html += trial.prompt; + } + // update the page content + display_element.innerHTML = html; + + // set image dimensions after image has loaded (so that we have access to naturalHeight/naturalWidth) + var img = display_element.querySelector('#jspsych-image-button-response-stimulus'); + if (trial.stimulus_height !== null) { + height = trial.stimulus_height; + if (trial.stimulus_width == null && trial.maintain_aspect_ratio) { + width = img.naturalWidth * (trial.stimulus_height/img.naturalHeight); + } + } else { + height = img.naturalHeight; + } + if (trial.stimulus_width !== null) { + width = trial.stimulus_width; + if (trial.stimulus_height == null && trial.maintain_aspect_ratio) { + height = img.naturalHeight * (trial.stimulus_width/img.naturalWidth); + } + } else if (!(trial.stimulus_height !== null & trial.maintain_aspect_ratio)) { + // if stimulus width is null, only use the image's natural width if the width value wasn't set + // in the if statement above, based on a specified height and maintain_aspect_ratio = true + width = img.naturalWidth; + } + img.style.height = height.toString() + "px"; + img.style.width = width.toString() + "px"; + } + + // start timing + var start_time = performance.now(); + + for (var i = 0; i < trial.choices.length; i++) { + display_element.querySelector('#jspsych-image-button-response-button-' + i).addEventListener('click', function(e){ + var choice = e.currentTarget.getAttribute('data-choice'); // don't use dataset for jsdom compatibility + after_response(choice); + }); + } + + // store response + var response = { + rt: null, + button: null + }; + + // function to handle responses by the subject + function after_response(choice) { + + // measure rt + var end_time = performance.now(); + var rt = end_time - start_time; + response.button = parseInt(choice); + response.rt = rt; + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-image-button-response-stimulus').className += ' responded'; + + // disable all the buttons after a response + var btns = document.querySelectorAll('.jspsych-image-button-response-button button'); + for(var i=0; i'; + // add prompt + if (trial.prompt !== null){ + html += trial.prompt; + } + // update the page content + display_element.innerHTML = html; + + // set image dimensions after image has loaded (so that we have access to naturalHeight/naturalWidth) + var img = display_element.querySelector('#jspsych-image-keyboard-response-stimulus'); + if (trial.stimulus_height !== null) { + height = trial.stimulus_height; + if (trial.stimulus_width == null && trial.maintain_aspect_ratio) { + width = img.naturalWidth * (trial.stimulus_height/img.naturalHeight); + } + } else { + height = img.naturalHeight; + } + if (trial.stimulus_width !== null) { + width = trial.stimulus_width; + if (trial.stimulus_height == null && trial.maintain_aspect_ratio) { + height = img.naturalHeight * (trial.stimulus_width/img.naturalWidth); + } + } else if (!(trial.stimulus_height !== null & trial.maintain_aspect_ratio)) { + // if stimulus width is null, only use the image's natural width if the width value wasn't set + // in the if statement above, based on a specified height and maintain_aspect_ratio = true + width = img.naturalWidth; + } + img.style.height = height.toString() + "px"; + img.style.width = width.toString() + "px"; + } + + // store response + var response = { + rt: null, + key: null + }; + + // function to end trial when it is time + var end_trial = function() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // kill keyboard listeners + if (typeof keyboardListener !== 'undefined') { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener); + } + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.key + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + // function to handle responses by the subject + var after_response = function(info) { + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-image-keyboard-response-stimulus').className += ' responded'; + + // only record the first response + if (response.key == null) { + response = info; + } + + if (trial.response_ends_trial) { + end_trial(); + } + }; + + // start the response listener + if (trial.choices != jsPsych.NO_KEYS) { + var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + + // hide stimulus if stimulus_duration is set + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('#jspsych-image-keyboard-response-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } else if (trial.response_ends_trial === false) { + console.warn("The experiment may be deadlocked. Try setting a trial duration or set response_ends_trial to true."); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-image-slider-response.js b/scripts/plugins/jspsych-image-slider-response.js new file mode 100644 index 0000000..47ad31f --- /dev/null +++ b/scripts/plugins/jspsych-image-slider-response.js @@ -0,0 +1,369 @@ +/** + * jspsych-image-slider-response + * a jspsych plugin for free response survey questions + * + * Josh de Leeuw + * + * documentation: docs.jspsych.org + * + */ + + +jsPsych.plugins['image-slider-response'] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('image-slider-response', 'stimulus', 'image'); + + plugin.info = { + name: 'image-slider-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimulus', + default: undefined, + description: 'The image to be displayed' + }, + stimulus_height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Image height', + default: null, + description: 'Set the image height in pixels' + }, + stimulus_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Image width', + default: null, + description: 'Set the image width in pixels' + }, + maintain_aspect_ratio: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Maintain aspect ratio', + default: true, + description: 'Maintain the aspect ratio after setting width or height' + }, + min: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Min slider', + default: 0, + description: 'Sets the minimum value of the slider.' + }, + max: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Max slider', + default: 100, + description: 'Sets the maximum value of the slider', + }, + slider_start: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Slider starting value', + default: 50, + description: 'Sets the starting value of the slider', + }, + step: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Step', + default: 1, + description: 'Sets the step of the slider' + }, + labels: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name:'Labels', + default: [], + array: true, + description: 'Labels of the slider.', + }, + slider_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name:'Slider width', + default: null, + description: 'Width of the slider in pixels.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + array: false, + description: 'Label of the button to advance.' + }, + require_movement: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Require movement', + default: false, + description: 'If true, the participant will have to move the slider before continuing.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the slider.' + }, + stimulus_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Stimulus duration', + default: null, + description: 'How long to hide the stimulus.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, trial will end when user makes a response.' + }, + render_on_canvas: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Render on canvas', + default: true, + description: 'If true, the image will be drawn onto a canvas element (prevents blank screen between consecutive images in some browsers).'+ + 'If false, the image will be shown via an img element.' + } + } + } + + plugin.trial = function(display_element, trial) { + + var height, width; + var html; + // half of the thumb width value from jspsych.css, used to adjust the label positions + var half_thumb_width = 7.5; + + if (trial.render_on_canvas) { + var image_drawn = false; + // first clear the display element (because the render_on_canvas method appends to display_element instead of overwriting it with .innerHTML) + if (display_element.hasChildNodes()) { + // can't loop through child list because the list will be modified by .removeChild() + while (display_element.firstChild) { + display_element.removeChild(display_element.firstChild); + } + } + // create wrapper div, canvas element and image + var content_wrapper = document.createElement('div'); + content_wrapper.id = "jspsych-image-slider-response-wrapper"; + content_wrapper.style.margin = "100px 0px"; + var canvas = document.createElement("canvas"); + canvas.id = "jspsych-image-slider-response-stimulus"; + canvas.style.margin = 0; + canvas.style.padding = 0; + var ctx = canvas.getContext("2d"); + var img = new Image(); + img.onload = function() { + // if image wasn't preloaded, then it will need to be drawn whenever it finishes loading + if (!image_drawn) { + getHeightWidth(); // only possible to get width/height after image loads + ctx.drawImage(img,0,0,width,height); + } + }; + img.src = trial.stimulus; + // get/set image height and width - this can only be done after image loads because uses image's naturalWidth/naturalHeight properties + function getHeightWidth() { + if (trial.stimulus_height !== null) { + height = trial.stimulus_height; + if (trial.stimulus_width == null && trial.maintain_aspect_ratio) { + width = img.naturalWidth * (trial.stimulus_height/img.naturalHeight); + } + } else { + height = img.naturalHeight; + } + if (trial.stimulus_width !== null) { + width = trial.stimulus_width; + if (trial.stimulus_height == null && trial.maintain_aspect_ratio) { + height = img.naturalHeight * (trial.stimulus_width/img.naturalWidth); + } + } else if (!(trial.stimulus_height !== null & trial.maintain_aspect_ratio)) { + // if stimulus width is null, only use the image's natural width if the width value wasn't set + // in the if statement above, based on a specified height and maintain_aspect_ratio = true + width = img.naturalWidth; + } + canvas.height = height; + canvas.width = width; + } + getHeightWidth(); // call now, in case image loads immediately (is cached) + // create container with slider and labels + var slider_container = document.createElement('div'); + slider_container.classList.add("jspsych-image-slider-response-container"); + slider_container.style.position = "relative"; + slider_container.style.margin = "0 auto 3em auto"; + if(trial.slider_width !== null){ + slider_container.style.width = trial.slider_width.toString()+'px'; + } + // create html string with slider and labels, and add to slider container + html =''; + html += '
' + for(var j=0; j < trial.labels.length; j++){ + var label_width_perc = 100/(trial.labels.length-1); + var percent_of_range = j * (100/(trial.labels.length - 1)); + var percent_dist_from_center = ((percent_of_range-50)/50)*100; + var offset = (percent_dist_from_center * half_thumb_width)/100; + html += '
'; + html += ''+trial.labels[j]+''; + html += '
' + } + html += '
'; + slider_container.innerHTML = html; + // add canvas and slider to content wrapper div + content_wrapper.insertBefore(canvas, content_wrapper.firstElementChild); + content_wrapper.insertBefore(slider_container, canvas.nextElementSibling); + // add content wrapper div to screen and draw image on canvas + display_element.insertBefore(content_wrapper, null); + if (img.complete && Number.isFinite(width) && Number.isFinite(height)) { + // if image has loaded and width/height have been set, then draw it now + // (don't rely on img onload function to draw image when image is in the cache, because that causes a delay in the image presentation) + ctx.drawImage(img,0,0,width,height); + image_drawn = true; + } + // add prompt if there is one + if (trial.prompt !== null) { + display_element.insertAdjacentHTML('beforeend', trial.prompt); + } + // add submit button + var submit_btn = document.createElement('button'); + submit_btn.id = "jspsych-image-slider-response-next"; + submit_btn.classList.add("jspsych-btn"); + submit_btn.disabled = (trial.require_movement) ? true : false; + submit_btn.innerHTML = trial.button_label; + display_element.insertBefore(submit_btn, display_element.nextElementSibling); + + } else { + + html = '
'; + html += '
'; + html += ''; + html += '
'; + html += '
'; + html += ''; + html += '
' + for(var j=0; j < trial.labels.length; j++){ + var label_width_perc = 100/(trial.labels.length-1); + var percent_of_range = j * (100/(trial.labels.length - 1)); + var percent_dist_from_center = ((percent_of_range-50)/50)*100; + var offset = (percent_dist_from_center * half_thumb_width)/100; + html += '
'; + html += ''+trial.labels[j]+''; + html += '
' + } + html += '
'; + html += '
'; + html += '
'; + + if (trial.prompt !== null){ + html += trial.prompt; + } + + // add submit button + html += ''; + + display_element.innerHTML = html; + + // set image dimensions after image has loaded (so that we have access to naturalHeight/naturalWidth) + var img = display_element.querySelector('img'); + if (trial.stimulus_height !== null) { + height = trial.stimulus_height; + if (trial.stimulus_width == null && trial.maintain_aspect_ratio) { + width = img.naturalWidth * (trial.stimulus_height/img.naturalHeight); + } + } else { + height = img.naturalHeight; + } + if (trial.stimulus_width !== null) { + width = trial.stimulus_width; + if (trial.stimulus_height == null && trial.maintain_aspect_ratio) { + height = img.naturalHeight * (trial.stimulus_width/img.naturalWidth); + } + } else if (!(trial.stimulus_height !== null & trial.maintain_aspect_ratio)) { + // if stimulus width is null, only use the image's natural width if the width value wasn't set + // in the if statement above, based on a specified height and maintain_aspect_ratio = true + width = img.naturalWidth; + } + img.style.height = height.toString() + "px"; + img.style.width = width.toString() + "px"; + } + + var response = { + rt: null, + response: null + }; + + if(trial.require_movement){ + display_element.querySelector('#jspsych-image-slider-response-response').addEventListener('click', function(){ + display_element.querySelector('#jspsych-image-slider-response-next').disabled = false; + }); + } + + display_element.querySelector('#jspsych-image-slider-response-next').addEventListener('click', function() { + // measure response time + var endTime = performance.now(); + response.rt = endTime - startTime; + response.response = display_element.querySelector('#jspsych-image-slider-response-response').valueAsNumber; + + if(trial.response_ends_trial){ + end_trial(); + } else { + display_element.querySelector('#jspsych-image-slider-response-next').disabled = true; + } + + }); + + function end_trial(){ + + jsPsych.pluginAPI.clearAllTimeouts(); + + // save data + var trialdata = { + rt: response.rt, + stimulus: trial.stimulus, + slider_start: trial.slider_start, + response: response.response + }; + + display_element.innerHTML = ''; + + // next trial + jsPsych.finishTrial(trialdata); + } + + if (trial.stimulus_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('#jspsych-image-slider-response-stimulus').style.visibility = 'hidden'; + }, trial.stimulus_duration); + } + + // end trial if trial_duration is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } + + var startTime = performance.now(); + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-instructions.js b/scripts/plugins/jspsych-instructions.js new file mode 100644 index 0000000..70ff290 --- /dev/null +++ b/scripts/plugins/jspsych-instructions.js @@ -0,0 +1,237 @@ +/* jspsych-instructions.js + * Josh de Leeuw + * + * This plugin displays text (including HTML formatted strings) during the experiment. + * Use it to show instructions, provide performance feedback, etc... + * + * Page numbers can be displayed to help with navigation by setting show_page_number + * to true. + * + * documentation: docs.jspsych.org + * + * + */ + +jsPsych.plugins.instructions = (function() { + + var plugin = {}; + + plugin.info = { + name: 'instructions', + description: '', + parameters: { + pages: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Pages', + default: undefined, + array: true, + description: 'Each element of the array is the content for a single page.' + }, + key_forward: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key forward', + default: 'ArrowRight', + description: 'The key the subject can press in order to advance to the next page.' + }, + key_backward: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key backward', + default: 'ArrowLeft', + description: 'The key that the subject can press to return to the previous page.' + }, + allow_backward: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Allow backward', + default: true, + description: 'If true, the subject can return to the previous page of the instructions.' + }, + allow_keys: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Allow keys', + default: true, + description: 'If true, the subject can use keyboard keys to navigate the pages.' + }, + show_clickable_nav: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Show clickable nav', + default: false, + description: 'If true, then a "Previous" and "Next" button will be displayed beneath the instructions.' + }, + show_page_number: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Show page number', + default: false, + description: 'If true, and clickable navigation is enabled, then Page x/y will be shown between the nav buttons.' + }, + page_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Page label', + default: 'Page', + description: 'The text that appears before x/y (current/total) pages displayed with show_page_number' + }, + button_label_previous: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label previous', + default: 'Previous', + description: 'The text that appears on the button to go backwards.' + }, + button_label_next: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label next', + default: 'Next', + description: 'The text that appears on the button to go forwards.' + } + } + } + + plugin.trial = function(display_element, trial) { + + var current_page = 0; + + var view_history = []; + + var start_time = performance.now(); + + var last_page_update_time = start_time; + + function btnListener(evt){ + evt.target.removeEventListener('click', btnListener); + if(this.id === "jspsych-instructions-back"){ + back(); + } + else if(this.id === 'jspsych-instructions-next'){ + next(); + } + } + + function show_current_page() { + var html = trial.pages[current_page]; + + var pagenum_display = ""; + if(trial.show_page_number) { + pagenum_display = ""+ trial.page_label + ' ' +(current_page+1)+"/"+trial.pages.length+""; + } + + if (trial.show_clickable_nav) { + + var nav_html = "
"; + if (trial.allow_backward) { + var allowed = (current_page > 0 )? '' : "disabled='disabled'"; + nav_html += ""; + } + if (trial.pages.length > 1 && trial.show_page_number) { + nav_html += pagenum_display; + } + nav_html += "
"; + + html += nav_html; + display_element.innerHTML = html; + if (current_page != 0 && trial.allow_backward) { + display_element.querySelector('#jspsych-instructions-back').addEventListener('click', btnListener); + } + + display_element.querySelector('#jspsych-instructions-next').addEventListener('click', btnListener); + } else { + if (trial.show_page_number && trial.pages.length > 1) { + // page numbers for non-mouse navigation + html += "
"+pagenum_display+"
" + } + display_element.innerHTML = html; + } + + } + + function next() { + + add_current_page_to_view_history() + + current_page++; + + // if done, finish up... + if (current_page >= trial.pages.length) { + endTrial(); + } else { + show_current_page(); + } + + } + + function back() { + + add_current_page_to_view_history() + + current_page--; + + show_current_page(); + } + + function add_current_page_to_view_history() { + + var current_time = performance.now(); + + var page_view_time = current_time - last_page_update_time; + + view_history.push({ + page_index: current_page, + viewing_time: page_view_time + }); + + last_page_update_time = current_time; + } + + function endTrial() { + + if (trial.allow_keys) { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboard_listener); + } + + display_element.innerHTML = ''; + + var trial_data = { + view_history: view_history, + rt: performance.now() - start_time + }; + + jsPsych.finishTrial(trial_data); + } + + var after_response = function(info) { + + // have to reinitialize this instead of letting it persist to prevent accidental skips of pages by holding down keys too long + keyboard_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: [trial.key_forward, trial.key_backward], + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + // check if key is forwards or backwards and update page + if (jsPsych.pluginAPI.compareKeys(info.key, trial.key_backward)) { + if (current_page !== 0 && trial.allow_backward) { + back(); + } + } + + if (jsPsych.pluginAPI.compareKeys(info.key, trial.key_forward)) { + next(); + } + + }; + + show_current_page(); + + if (trial.allow_keys) { + var keyboard_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: [trial.key_forward, trial.key_backward], + rt_method: 'performance', + persist: false + }); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-maxdiff.js b/scripts/plugins/jspsych-maxdiff.js new file mode 100644 index 0000000..d8572e0 --- /dev/null +++ b/scripts/plugins/jspsych-maxdiff.js @@ -0,0 +1,173 @@ +/** + * jspsych-maxdiff + * Angus Hughes + * + * a jspsych plugin for maxdiff/conjoint analysis designs + * + */ + +jsPsych.plugins['maxdiff'] = (function () { + + var plugin = {}; + + plugin.info = { + name: 'maxdiff', + description: '', + parameters: { + alternatives: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Alternatives', + array: true, + default: undefined, + description: 'Alternatives presented in the maxdiff table.' + }, + labels: { + type: jsPsych.plugins.parameterType.STRING, + array: true, + pretty_name: 'Labels', + default: undefined, + description: 'Labels to display for left and right response columns.' + }, + randomize_alternative_order: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Randomize Alternative Order', + default: false, + description: 'If true, the order of the alternatives will be randomized' + }, + preamble: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Preamble', + default: '', + description: 'String to display at top of the page.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button Label', + default: 'Continue', + description: 'Label of the button.' + }, + required: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Required', + default: false, + description: 'Makes answering the alternative required.' + } + } + } + + plugin.trial = function (display_element, trial) { + + var html = ""; + // inject CSS for trial + html += ''; + + // show preamble text + if (trial.preamble !== null) { + html += '
' + trial.preamble + '
'; + } + html += '
'; + + // add maxdiff options /// + // first generate alternative order, randomized here as opposed to randomizing the order of alternatives + // so that the data are always associated with the same alternative regardless of order. + var alternative_order = []; + for (var i = 0; i < trial.alternatives.length; i++) { + alternative_order.push(i); + } + if (trial.randomize_alternative_order) { + alternative_order = jsPsych.randomization.shuffle(alternative_order); + } + + // Start with column headings + var maxdiff_table = ''; + + // construct each row of the maxdiff table + for (var i = 0; i < trial.alternatives.length; i++) { + var alternative = trial.alternatives[alternative_order[i]]; + // add alternative + maxdiff_table += ''; + maxdiff_table += ''; + maxdiff_table += ''; + } + maxdiff_table += '
' + trial.labels[0] + '' + trial.labels[1] + '

' + alternative + '


'; + html += maxdiff_table; + + // add submit button + var enable_submit = trial.required == true ? 'disabled = "disabled"' : ''; + html += ''; + html += '
'; + + display_element.innerHTML = html; + + // function to control responses + // first checks that the same alternative cannot be endorsed in the left and right columns simultaneously. + // then enables the submit button if the trial is required. + const left_right = ["left", "right"] + left_right.forEach(function(p) { + // Get all elements either 'left' or 'right' + document.getElementsByName(p).forEach(function(alt) { + alt.addEventListener('click', function() { + // Find the opposite (if left, then right & vice versa) identified by the class (jspsych-maxdiff-alt-1, 2, etc) + var op = alt.name == 'left' ? 'right' : 'left'; + var n = document.getElementsByClassName(alt.className).namedItem(op); + // If it's checked, uncheck it. + if (n.checked) { + n.checked = false; + } + + // check response + if (trial.required){ + // Now check if one of both left and right have been enabled to allow submission + var left_checked = [...document.getElementsByName('left')].some(c => c.checked); + var right_checked = [...document.getElementsByName('right')].some(c => c.checked); + if (left_checked && right_checked) { + document.getElementById("jspsych-maxdiff-next").disabled = false; + } else { + document.getElementById("jspsych-maxdiff-next").disabled = true; + } + } + }); + }); + }); + + // Get the data once the submit button is clicked + // Get the data once the submit button is clicked + display_element.querySelector('#jspsych-maxdiff-form').addEventListener('submit', function(e){ + e.preventDefault(); + + // measure response time + var endTime = performance.now(); + var response_time = endTime - startTime; + + // get the alternative by the data-name attribute, allowing a null response if unchecked + get_response = function(side){ + var col = display_element.querySelectorAll('[name=\"' + side + '\"]:checked')[0]; + if (col === undefined){ + return null; + } else { + var i = parseInt(col.getAttribute('data-name')); + return trial.alternatives[i]; + } + } + + // data saving + var trial_data = { + rt: response_time, + labels: {left: trial.labels[0], right: trial.labels[1]}, + response: {left: get_response('left'), right: get_response('right')} + }; + + // next trial + jsPsych.finishTrial(trial_data); + }); + + var startTime = performance.now(); + }; + + return plugin; +})(); \ No newline at end of file diff --git a/scripts/plugins/jspsych-palmer.js b/scripts/plugins/jspsych-palmer.js deleted file mode 100644 index cbd7e0c..0000000 --- a/scripts/plugins/jspsych-palmer.js +++ /dev/null @@ -1,426 +0,0 @@ -/** - * jspsych-palmer - * Josh de Leeuw (October 2013) - * - * a jspsych plugin for presenting and querying about stimuli modeled after - * - * Palmer, S. (1977). Hierarchical Structure in Perceptual Representation. Cognitive Psychology, 9, 441. - * - * and - * - * Goldstone, R. L., Rogosky, B. J., Pevtzow, R., & Blair, M. (2005). Perceptual and semantic reorganization during category learning. - * In H. Cohen & C. Lefebvre (Eds.) Handbook of Categorization in Cognitive Science. (pp. 651-678). Amsterdam: Elsevier. - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-palmer - * - */ - -(function($) { - jsPsych.palmer = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['data']); - - var trials = []; - for (var i = 0; i < params.configurations.length; i++) { - var trial = { - type: "palmer", - configurations: params.configurations[i], - editable: (typeof params.editable === 'undefined') ? false : params.editable, - show_feedback: (typeof params.show_feedback === 'undefined') ? false : params.show_feedback, - grid_spacing: params.grid_spacing || 75, - square_size: params.square_size || 3, - circle_radius: params.circle_radius || 20, - timing_item: params.timing_item || 1000, - timing_post_trial: (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial, - timing_feedback: params.timing_feedback || 1000, - prompt: (typeof params.prompt === 'undefined') ? "" : params.prompt, - data: (typeof params.data === 'undefined') ? {} : params.data[i] - }; - - trials.push(trial); - } - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - // variables to keep track of user interaction - var start_circle = -1; - var end_circle = -1; - var line_started = false; - - var size = trial.grid_spacing * (trial.square_size + 1); - - display_element.append($("
", { - css: { - width: size + "px", - height: size + "px" - } - })); - - var paper = Raphael("jspsych-palmer-raphaelCanvas", size, size); - - // create the circles at the vertices. - var circles = []; - var node_idx = 0; - for (var i = 1; i <= trial.square_size; i++) { - for (var j = 1; j <= trial.square_size; j++) { - var circle = paper.circle(trial.grid_spacing * j, trial.grid_spacing * i, trial.circle_radius); - circle.attr("fill", "#000").attr("stroke-width", "0").attr("stroke", "#000").data("node", node_idx); - - if (trial.editable) { - circle.hover( - - function() { - this.attr("stroke-width", "2"); - //this.attr("stroke", "#000"); - }, - - function() { - this.attr("stroke-width", "0"); - //this.attr("stroke", "#fff") - }).click( - - function() { - if (!line_started) { - line_started = true; - start_circle = this.data("node"); - this.attr("fill", "#777").attr("stroke", "#777"); - } - else { - end_circle = this.data("node"); - draw_connection(start_circle, end_circle); - } - }); - } - node_idx++; - circles.push(circle); - } - } - - function draw_connection(start_circle, end_circle) { - var the_line = getLineIndex(start_circle, end_circle); - if (the_line > -1) { - toggle_line(the_line); - } - // reset highlighting on circles - for (var i = 0; i < circles.length; i++) { - circles[i].attr("fill", "#000").attr("stroke", "#000"); - } - // cleanup the variables - line_started = false; - start_circle = -1; - end_circle = -1; - } - - // create all possible lines that connect circles - var horizontal_lines = []; - var vertical_lines = []; - var backslash_lines = []; - var forwardslash_lines = []; - - for (var i = 0; i < trial.square_size; i++) { - for (var j = 0; j < trial.square_size; j++) { - var current_item = (i * trial.square_size) + j; - // add horizontal connections - if (j < (trial.square_size - 1)) { - horizontal_lines.push([current_item, current_item + 1]); - } - // add vertical connections - if (i < (trial.square_size - 1)) { - vertical_lines.push([current_item, current_item + trial.square_size]); - } - // add diagonal backslash connections - if (i < (trial.square_size - 1) && j < (trial.square_size - 1)) { - backslash_lines.push([current_item, current_item + trial.square_size + 1]); - } - // add diagonal forwardslash connections - if (i < (trial.square_size - 1) && j > 0) { - forwardslash_lines.push([current_item, current_item + trial.square_size - 1]); - } - } - } - - var lines = horizontal_lines.concat(vertical_lines).concat(backslash_lines).concat(forwardslash_lines); - - // actually draw the lines - var lineIsVisible = []; - var lineElements = []; - - for (var i = 0; i < lines.length; i++) { - var line = paper.path("M" + circles[lines[i][0]].attr("cx") + " " + circles[lines[i][0]].attr("cy") + "L" + circles[lines[i][1]].attr("cx") + " " + circles[lines[i][1]].attr("cy")).attr("stroke-width", "8").attr("stroke", "#000"); - line.hide(); - lineElements.push(line); - lineIsVisible.push(0); - } - - // define some helper functions to toggle lines on and off - - // this function gets the index of a line based on the two circles it connects - function getLineIndex(start_circle, end_circle) { - var the_line = -1; - for (var i = 0; i < lines.length; i++) { - if ((start_circle == lines[i][0] && end_circle == lines[i][1]) || (start_circle == lines[i][1] && end_circle == lines[i][0])) { - the_line = i; - break; - } - } - return the_line; - } - - // this function turns a line on/off based on the index (the_line) - function toggle_line(the_line) { - if (the_line > -1) { - if (lineIsVisible[the_line] === 0) { - lineElements[the_line].show(); - lineElements[the_line].toBack(); - lineIsVisible[the_line] = 1; - } - else { - lineElements[the_line].hide(); - lineElements[the_line].toBack(); - lineIsVisible[the_line] = 0; - } - } - } - - // this function takes an array of length = num lines, and displays the line whereever there - // is a 1 in the array. - function showConfiguration(configuration) { - for (var i = 0; i < configuration.length; i++) { - if (configuration[i] != lineIsVisible[i]) { - toggle_line(i); - } - } - } - - // highlight a line - function highlightLine(line) { - lineElements[line].attr("stroke", "#f00"); - } - - // start recording the time - var startTime = (new Date()).getTime(); - - // what kind of trial are we doing? - // if trial.editable is true, then we will let the user interact with the stimulus to create - // something, e.g. for a reconstruction probe. - // need a way for the user to submit when they are done in that case... - if (trial.editable) { - display_element.append($('')); - $('#jspsych-palmer-submitButton').click(function() { - save_data(); - }); - } - - // if trial.editable is false, then we are just showing a pre-determined configuration. - // for now, the only option will be to display for a fixed amount of time. - // future ideas: allow for key response, to enable things like n-back, same/different, etc.. - if (!trial.editable) { - showConfiguration(trial.configurations); - - setTimeout(function() { - save_data(); - }, trial.timing_item); - } - - if (trial.prompt !== "") { - display_element.append($('
')); - $("#jspsych-palmer-prompt").html(trial.prompt); - } - - function arrayDifferences(arr1, arr2) { - var n_diff = 0; - for (var i = 0; i < arr1.length; i++) { - if (arr1[i] != arr2[i]) { - n_diff++; - } - } - return n_diff; - } - - // save data - function save_data() { - - // measure RT - var endTime = (new Date()).getTime(); - var response_time = endTime - startTime; - - // check if configuration is correct - // this is meaningless for trials where the user can't edit - var n_diff = arrayDifferences(trial.configurations, lineIsVisible); - var correct = (n_diff === 0); - - block.writeData($.extend({}, { - "trial_type": "palmer", - "trial_index": block.trial_idx, - "configuration": JSON.stringify(lineIsVisible), - "target_configuration": JSON.stringify(trial.configurations), - "rt": response_time, - "correct": correct, - "num_wrong": n_diff, - }, trial.data)); - - if (trial.editable && trial.show_feedback) { - // hide the button - $('#jspsych-palmer-submitButton').hide(); - $('#jspsych-palmer-prompt').hide(); - - showConfiguration(trial.configurations); - var feedback = ""; - if (correct) { - feedback = "Correct!"; - } - else { - if (n_diff > 1) { - feedback = "You missed " + n_diff + " lines. The correct symbol is shown above."; - } - else { - feedback = "You missed 1 line. The correct symbol is shown above."; - } - } - display_element.append($.parseHTML("

" + feedback + "

")); - - setTimeout(function() { - next_trial(); - }, trial.timing_feedback); - - } - else { - next_trial(); - } - } - - function next_trial() { - - display_element.html(''); - - // next trial - if (trial.timing_post_trial > 0) { - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } - else { - block.next(); - } - - } - - - }; - - // method for drawing palmer stimuli. - // returns the string description of svg element containing the stimulus - // requires raphaeljs library -> www.raphaeljs.com - - plugin.generate_stimulus = function(square_size, grid_spacing, circle_radius, configuration) { - - // create a div to hold the generated svg object - var stim_div = $('body').append('
'); - - var size = grid_spacing * (square_size + 1); - - // create the svg raphael object - var paper = Raphael("jspsych-palmer-temp-stim", size, size); - - // create the circles at the vertices. - var circles = []; - var node_idx = 0; - for (var i = 1; i <= square_size; i++) { - for (var j = 1; j <= square_size; j++) { - var circle = paper.circle(grid_spacing * j, grid_spacing * i, circle_radius); - circle.attr("fill", "#000").attr("stroke-width", "0").attr("stroke", "#000").data("node", node_idx); - node_idx++; - circles.push(circle); - } - } - - // create all possible lines that connect circles - var horizontal_lines = []; - var vertical_lines = []; - var backslash_lines = []; - var forwardslash_lines = []; - - for (var i = 0; i < square_size; i++) { - for (var j = 0; j < square_size; j++) { - var current_item = (i * square_size) + j; - // add horizontal connections - if (j < (square_size - 1)) { - horizontal_lines.push([current_item, current_item + 1]); - } - // add vertical connections - if (i < (square_size - 1)) { - vertical_lines.push([current_item, current_item + square_size]); - } - // add diagonal backslash connections - if (i < (square_size - 1) && j < (square_size - 1)) { - backslash_lines.push([current_item, current_item + square_size + 1]); - } - // add diagonal forwardslash connections - if (i < (square_size - 1) && j > 0) { - forwardslash_lines.push([current_item, current_item + square_size - 1]); - } - } - } - - var lines = horizontal_lines.concat(vertical_lines).concat(backslash_lines).concat(forwardslash_lines); - - // actually draw the lines - var lineIsVisible = []; - var lineElements = []; - - for (var i = 0; i < lines.length; i++) { - var line = paper.path("M" + circles[lines[i][0]].attr("cx") + " " + circles[lines[i][0]].attr("cy") + "L" + circles[lines[i][1]].attr("cx") + " " + circles[lines[i][1]].attr("cy")).attr("stroke-width", "8").attr("stroke", "#000"); - line.hide(); - lineElements.push(line); - lineIsVisible.push(0); - } - - // define some helper functions to toggle lines on and off - - // this function turns a line on/off based on the index (the_line) - function toggle_line(the_line) { - if (the_line > -1) { - if (lineIsVisible[the_line] === 0) { - lineElements[the_line].show(); - lineElements[the_line].toBack(); - lineIsVisible[the_line] = 1; - } - else { - lineElements[the_line].hide(); - lineElements[the_line].toBack(); - lineIsVisible[the_line] = 0; - } - } - } - - // displays the line wherever there - // is a 1 in the array. - // showConfiguration(configuration) - for (var i = 0; i < configuration.length; i++) { - if (configuration[i] == 1) { - toggle_line(i); - } - } - - - var svg = $("#jspsych-palmer-temp-stim").html(); - - $('#jspsych-palmer-temp-stim').remove(); - - return svg; - }; - - return plugin; - })(); -})(jQuery); diff --git a/scripts/plugins/jspsych-preload.js b/scripts/plugins/jspsych-preload.js new file mode 100644 index 0000000..8a8e1c0 --- /dev/null +++ b/scripts/plugins/jspsych-preload.js @@ -0,0 +1,345 @@ +/** + * jspsych-preload + * documentation: docs.jspsych.org + **/ + +jsPsych.plugins['preload'] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'preload', + description: '', + parameters: { + auto_preload: { + type: jsPsych.plugins.parameterType.BOOL, + default: false, + description: 'Whether or not to automatically preload any media files based on the timeline passed to jsPsych.init.' + }, + trials: { + type: jsPsych.plugins.parameterType.TIMELINE, + default: [], + description: 'Array with a timeline of trials to automatically preload. If one or more trial objects is provided, '+ + 'then the plugin will attempt to preload the media files used in the trial(s).' + }, + images: { + type: jsPsych.plugins.parameterType.STRING, + default: [], + description: 'Array with one or more image files to load. This parameter is often used in cases where media files cannot '+ + 'be automatically preloaded based on the timeline, e.g. because the media files are passed into an image plugin/parameter with '+ + 'timeline variables or dynamic parameters, or because the image is embedded in an HTML string.' + }, + audio: { + type: jsPsych.plugins.parameterType.STRING, + default: [], + description: 'Array with one or more audio files to load. This parameter is often used in cases where media files cannot '+ + 'be automatically preloaded based on the timeline, e.g. because the media files are passed into an audio plugin/parameter with '+ + 'timeline variables or dynamic parameters, or because the audio is embedded in an HTML string.' + }, + video: { + type: jsPsych.plugins.parameterType.STRING, + default: [], + description: 'Array with one or more video files to load. This parameter is often used in cases where media files cannot '+ + 'be automatically preloaded based on the timeline, e.g. because the media files are passed into a video plugin/parameter with '+ + 'timeline variables or dynamic parameters, or because the video is embedded in an HTML string.' + }, + message: { + type: jsPsych.plugins.parameterType.HTML_STRING, + default: null, + description: 'HTML-formatted message to be shown above the progress bar while the files are loading.' + }, + show_progress_bar: { + type: jsPsych.plugins.parameterType.BOOL, + default: true, + description: 'Whether or not to show the loading progress bar.' + }, + continue_after_error: { + type: jsPsych.plugins.parameterType.BOOL, + default: false, + description: 'Whether or not to continue with the experiment if a loading error occurs. If false, then if a loading error occurs, '+ + 'the error_message will be shown on the page and the trial will not end. If true, then if if a loading error occurs, the trial will end '+ + 'and preloading failure will be logged in the trial data.' + }, + error_message: { + type: jsPsych.plugins.parameterType.HTML_STRING, + default: 'The experiment failed to load.', + description: 'Error message to show on the page in case of any loading errors. This parameter is only relevant when continue_after_error is false.' + }, + show_detailed_errors: { + type: jsPsych.plugins.parameterType.BOOL, + default: false, + description: 'Whether or not to show a detailed error message on the page. If true, then detailed error messages will be shown on the '+ + 'page for all files that failed to load, along with the general error_message. This parameter is only relevant when continue_after_error is false.' + }, + max_load_time: { + type: jsPsych.plugins.parameterType.INT, + default: null, + description: 'The maximum amount of time that the plugin should wait before stopping the preload and either ending the trial '+ + '(if continue_after_error is true) or stopping the experiment with an error message (if continue_after_error is false). '+ + 'If null, the plugin will wait indefintely for the files to load.' + }, + on_error: { + type: jsPsych.plugins.parameterType.FUNCTION, + default: null, + description: 'Function to be called after a file fails to load. The function takes the file name as its only argument.' + }, + on_success: { + type: jsPsych.plugins.parameterType.FUNCTION, + default: null, + description: 'Function to be called after a file loads successfully. The function takes the file name as its only argument.' + } + } + } + + plugin.trial = function(display_element, trial) { + + var success = null; + var timeout = false; + var failed_images = []; + var failed_audio = []; + var failed_video = []; + var detailed_errors = []; + var in_safe_mode = jsPsych.getSafeModeStatus(); + + // create list of media to preload // + + var images = []; + var audio = []; + var video = []; + + if(trial.auto_preload){ + var auto_preload = jsPsych.pluginAPI.getAutoPreloadList(); + images = images.concat(auto_preload.images); + audio = audio.concat(auto_preload.audio); + video = video.concat(auto_preload.video); + } + + if(trial.trials.length > 0){ + var trial_preloads = jsPsych.pluginAPI.getAutoPreloadList(trial.trials); + images = images.concat(trial_preloads.images); + audio = audio.concat(trial_preloads.audio); + video = video.concat(trial_preloads.video); + } + + images = images.concat(trial.images); + audio = audio.concat(trial.audio); + video = video.concat(trial.video); + + images = jsPsych.utils.unique(jsPsych.utils.flatten(images)); + audio = jsPsych.utils.unique(jsPsych.utils.flatten(audio)); + video = jsPsych.utils.unique(jsPsych.utils.flatten(video)); + + if (in_safe_mode) { + // don't preload video if in safe mode (experiment is running via file protocol) + video = []; + } + + // render display of message and progress bar + + var html = ''; + + if(trial.message !== null){ + html += trial.message; + } + + if(trial.show_progress_bar){ + html += ` +
+
+
`; + } + + display_element.innerHTML = html; + + // do preloading + + if(trial.max_load_time !== null){ + jsPsych.pluginAPI.setTimeout(on_timeout, trial.max_load_time); + } + + var total_n = images.length + audio.length + video.length; + var loaded = 0; // success or error count + var loaded_success = 0; // success count + + if (total_n == 0) { + on_success(); + } else { + function load_video(cb){ + jsPsych.pluginAPI.preloadVideo(video, cb, file_loading_success, file_loading_error); + } + function load_audio(cb){ + jsPsych.pluginAPI.preloadAudio(audio, cb, file_loading_success, file_loading_error); + } + function load_images(cb){ + jsPsych.pluginAPI.preloadImages(images, cb, file_loading_success, file_loading_error); + } + if (video.length > 0) { load_video(function () { }) } + if (audio.length > 0) { load_audio(function () { }) } + if (images.length > 0) { load_images(function () { }) } + } + + // helper functions and callbacks + + function update_loading_progress_bar(){ + loaded++; + if(trial.show_progress_bar){ + var percent_loaded = (loaded/total_n)*100; + var preload_progress_bar = jsPsych.getDisplayElement().querySelector('#jspsych-loading-progress-bar'); + if (preload_progress_bar !== null) { + preload_progress_bar.style.width = percent_loaded+"%"; + } + } + } + + // called when a single file loading fails + function file_loading_error(e) { + // update progress bar even if there's an error + update_loading_progress_bar(); + // change success flag after first file loading error + if (success == null) { + success = false; + } + // add file to failed media list + var source = "unknown file"; + if (e.source) { + source = e.source; + } + if (e.error && e.error.path && e.error.path.length > 0) { + if (e.error.path[0].localName == "img") { + failed_images.push(source); + } else if (e.error.path[0].localName == "audio") { + failed_audio.push(source); + } else if (e.error.path[0].localName == "video") { + failed_video.push(source); + } + } + // construct detailed error message + var err_msg = '

Error loading file: '+source+'
'; + if (e.error.statusText) { + err_msg += 'File request response status: '+e.error.statusText+'
'; + } + if (e.error == "404") { + err_msg += '404 - file not found.
'; + } + if (typeof e.error.loaded !== 'undefined' && e.error.loaded !== null && e.error.loaded !== 0) { + err_msg += e.error.loaded+' bytes transferred.'; + } else { + err_msg += 'File did not begin loading. Check that file path is correct and reachable by the browser,
'+ + 'and that loading is not blocked by cross-origin resource sharing (CORS) errors.'; + } + err_msg += '

'; + detailed_errors.push(err_msg); + // call trial's on_error function + after_error(source); + // if this is the last file + if (loaded == total_n) { + if (trial.continue_after_error) { + // if continue_after_error is false, then stop with an error + end_trial(); + } else { + // otherwise end the trial and continue + stop_with_error_message(); + } + } + } + + // called when a single file loads successfully + function file_loading_success(source) { + update_loading_progress_bar(); + // call trial's on_success function + after_success(source); + loaded_success++; + if (loaded_success == total_n) { + // if this is the last file and all loaded successfully, call success function + on_success(); + } else if (loaded == total_n) { + // if this is the last file and there was at least one error + if (trial.continue_after_error) { + // end the trial and continue with experiment + end_trial(); + } else { + // if continue_after_error is false, then stop with an error + stop_with_error_message(); + } + } + } + + // called if all files load successfully + function on_success() { + if (typeof timeout !== 'undefined' && timeout === false) { + // clear timeout immediately after finishing, to handle race condition with max_load_time + jsPsych.pluginAPI.clearAllTimeouts(); + // need to call cancel preload function to clear global jsPsych preload_request list, even when they've all succeeded + jsPsych.pluginAPI.cancelPreloads(); + success = true; + end_trial(); + } + } + + // called if all_files haven't finished loading when max_load_time is reached + function on_timeout() { + //console.log('timeout fired'); + jsPsych.pluginAPI.cancelPreloads(); + if (typeof success !== 'undefined' && (success === false || success === null)) { + timeout = true; + if (loaded_success < total_n) { + success = false; + } + after_error('timeout'); // call trial's on_error event handler here, in case loading timed out with no file errors + detailed_errors.push('

Loading timed out.
'+ + 'Consider compressing your stimuli files, loading your files in smaller batches,
'+ + 'and/or increasing the max_load_time parameter.

'); + if (trial.continue_after_error) { + end_trial(); + } else { + stop_with_error_message(); + } + } + } + + function stop_with_error_message() { + jsPsych.pluginAPI.clearAllTimeouts(); + jsPsych.pluginAPI.cancelPreloads(); + // show error message + display_element.innerHTML = trial.error_message; + // show detailed errors, if necessary + if (trial.show_detailed_errors) { + display_element.innerHTML += '

Error details:

'; + detailed_errors.forEach(function(e) { + display_element.innerHTML += e; + }); + } + } + + function after_error(source) { + // call on_error function and pass file name + if (trial.on_error !== null) { + trial.on_error(source); + } + } + function after_success(source) { + // call on_success function and pass file name + if (trial.on_success !== null) { + trial.on_success(source); + } + } + + function end_trial(){ + // clear timeout again when end_trial is called, to handle race condition with max_load_time + jsPsych.pluginAPI.clearAllTimeouts(); + var trial_data = { + success: success, + timeout: timeout, + failed_images: failed_images, + failed_audio: failed_audio, + failed_video: failed_video + }; + // clear the display + display_element.innerHTML = ''; + jsPsych.finishTrial(trial_data); + } + }; + + return plugin; + })(); + \ No newline at end of file diff --git a/scripts/plugins/jspsych-rdk.js b/scripts/plugins/jspsych-rdk.js new file mode 100644 index 0000000..80674f1 --- /dev/null +++ b/scripts/plugins/jspsych-rdk.js @@ -0,0 +1,1373 @@ +/* + + RDK plugin for JsPsych + ---------------------- + + This code was created in the Consciousness and Metacognition Lab at UCLA, + under the supervision of Brian Odegaard and Hakwan Lau + + We would appreciate it if you cited this paper when you use the RDK: + Rajananda, S., Lau, H. & Odegaard, B., (2018). A Random-Dot Kinematogram for Web-Based Vision Research. Journal of Open Research Software. 6(1), p.6. DOI: [http://doi.org/10.5334/jors.194] + + ---------------------- + + Copyright (C) 2017 Sivananda Rajananda + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +*/ + + +jsPsych.plugins["rdk"] = (function() { + + var plugin = {}; + + plugin.info = { + name: "rdk", + parameters: { + choices: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: "Choices", + default: jsPsych.ALL_KEYS, + array: true, + description: "The valid keys that the subject can press to indicate a response" + }, + correct_choice: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: "Correct choice", + default: undefined, + array: true, + description: "The correct keys for that trial" + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Trial duration", + default: 500, + description: "The length of stimulus presentation" + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: "Response ends trial", + default: true, + description: "If true, then any valid key will end the trial" + }, + number_of_apertures: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Number of apertures", + default: 1, + description: "The number of RDK apertures (If more than one, make sure to separate them by setting aperture_center_x and aperture_center_y for each RDK)" + }, + number_of_dots: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Number of dots", + default: 300, + description: "The number of dots per set in the stimulus" + }, + number_of_sets: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Number of sets", + default: 1, + description: "The number of sets of dots to cycle through" + }, + coherent_direction: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Coherent direction", + default: 0, + description: "The direction of coherent motion in degrees" + }, + coherence: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: "Coherence", + default: 0.5, + description: "The percentage of dots moving in the coherent direction" + }, + opposite_coherence: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: "Opposite coherence", + default: 0, + description: "The percentage of dots moving in the direction opposite of the coherent direction" + }, + dot_radius: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Dot radius", + default: 2, + description: "The radius of the dots in pixels" + }, + dot_life: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Dot life", + default: -1, + description: "The number of frames that pass before each dot disappears and reappears somewhere else" + }, + move_distance: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Move distance", + default: 1, + description: "The distance in pixels each dot moves per frame" + }, + aperture_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Aperture width", + default: 600, + description: "The width of the aperture in pixels" + }, + aperture_height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Aperture height", + default: 400, + description: "The height of the aperture in pixels" + }, + dot_color: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: "Dot color", + default: "white", + description: "The color of the dots" + }, + background_color: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: "Background color", + default: "gray", + description: "The background of the stimulus" + }, + RDK_type: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "RDK type", + default: 3, + description: "The Type of RDK (refer to documentation for details)" + }, + aperture_type: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Aperture Type", + default: 2, + description: "The shape of the aperture" + }, + reinsert_type: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Reinsert type", + default: 2, + description: "The reinsertion rule for dots that move out of the aperture" + }, + aperture_center_x: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Aperture center X", + default: window.innerWidth/2, + description: "The x-coordinate of the center of the aperture" + }, + aperture_center_y: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Aperture center Y", + default: window.innerHeight/2, + description: "The y-coordinate of the center of the aperture" + }, + fixation_cross: { + type: jsPsych.plugins.parameterType.INT, //boolean + pretty_name: "Fixation cross", + default: false, + description: "If true, then a fixation cross will be present in the middle of the screen" + }, + fixation_cross_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Fixation cross width", + default: 20, + description: "The width of the fixation cross in pixels" + }, + fixation_cross_height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Fixation cross height", + default: 20, + description: "The height of the fixation cross in pixels" + }, + fixation_cross_color: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: "Fixation cross color", + default: "black", + description: "The color of the fixation cross" + }, + fixation_cross_thickness: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Fixation cross thickness", + default: 1, + description: "The thickness of the fixation cross" + }, + border: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: "Border", + default: false, + description: "The presence of a border around the aperture" + }, + border_thickness: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Border width", + default: 1, + description: "The thickness of the border in pixels" + }, + border_color: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: "Border Color", + default: 1, + description: "The color of the border" + } + } + } + + + //BEGINNING OF TRIAL + plugin.trial = function(display_element, trial) { + + //-------------------------------------- + //---------SET PARAMETERS BEGIN--------- + //-------------------------------------- + + + //Note on '||' logical operator: If the first option is 'undefined', it evalutes to 'false' and the second option is returned as the assignment + trial.choices = assignParameterValue(trial.choices, []); + trial.correct_choice = assignParameterValue(trial.correct_choice, undefined); + trial.trial_duration = assignParameterValue(trial.trial_duration, 500); + trial.response_ends_trial = assignParameterValue(trial.response_ends_trial, true); + trial.number_of_apertures = assignParameterValue(trial.number_of_apertures, 1); + trial.number_of_dots = assignParameterValue(trial.number_of_dots, 300); + trial.number_of_sets = assignParameterValue(trial.number_of_sets, 1); + trial.coherent_direction = assignParameterValue(trial.coherent_direction, 0); + trial.coherence = assignParameterValue(trial.coherence, 0.5); + trial.opposite_coherence = assignParameterValue(trial.opposite_coherence, 0); + trial.dot_radius = assignParameterValue(trial.dot_radius, 2); + trial.dot_life = assignParameterValue(trial.dot_life, -1); + trial.move_distance = assignParameterValue(trial.move_distance, 1); + trial.aperture_width = assignParameterValue(trial.aperture_width, 600); + trial.aperture_height = assignParameterValue(trial.aperture_height, 400); + trial.dot_color = assignParameterValue(trial.dot_color, "white"); + trial.background_color = assignParameterValue(trial.background_color, "gray"); + trial.RDK_type = assignParameterValue(trial.RDK_type, 3); + trial.aperture_type = assignParameterValue(trial.aperture_type, 2); + trial.reinsert_type = assignParameterValue(trial.reinsert_type, 2); + trial.aperture_center_x = assignParameterValue(trial.aperture_center_x, window.innerWidth/2); + trial.aperture_center_y = assignParameterValue(trial.aperture_center_y, window.innerHeight/2); + trial.fixation_cross = assignParameterValue(trial.fixation_cross, false); + trial.fixation_cross_width = assignParameterValue(trial.fixation_cross_width, 20); + trial.fixation_cross_height = assignParameterValue(trial.fixation_cross_height, 20); + trial.fixation_cross_color = assignParameterValue(trial.fixation_cross_color, "black"); + trial.fixation_cross_thickness = assignParameterValue(trial.fixation_cross_thickness, 1); + trial.border = assignParameterValue(trial.border, false); + trial.border_thickness = assignParameterValue(trial.border_thickness, 1); + trial.border_color = assignParameterValue(trial.border_color, "black"); + + + //For square and circle, set the aperture height == aperture width + if (apertureType == 1 || apertureType == 3) { + trial.aperture_height = trial.aperture_width; + } + + //Convert the parameter variables to those that the code below can use + + var nApertures = trial.number_of_apertures; //The number of apertures + var nDots = trial.number_of_dots; //Number of dots per set (equivalent to number of dots per frame) + var nSets = trial.number_of_sets; //Number of sets to cycle through per frame + var coherentDirection = trial.coherent_direction; //The direction of the coherentDots in degrees. Starts at 3 o'clock and goes counterclockwise (0 == rightwards, 90 == upwards, 180 == leftwards, 270 == downwards), range 0 - 360 + var coherence = trial.coherence; //Proportion of dots to move together, range from 0 to 1 + var oppositeCoherence = trial.opposite_coherence; // The coherence for the dots going the opposite direction as the coherent dots + var dotRadius = trial.dot_radius; //Radius of each dot in pixels + var dotLife = trial.dot_life; //How many frames a dot will keep following its trajectory before it is redrawn at a random location. -1 denotes infinite life (the dot will only be redrawn if it reaches the end of the aperture). + var moveDistance = trial.move_distance; //How many pixels the dots move per frame + var apertureWidth = trial.aperture_width; // How many pixels wide the aperture is. For square aperture this will be the both height and width. For circle, this will be the diameter. + var apertureHeight = trial.aperture_height; //How many pixels high the aperture is. Only relevant for ellipse and rectangle apertures. For circle and square, this is ignored. + var dotColor = trial.dot_color; //Color of the dots + var backgroundColor = trial.background_color; //Color of the background + var apertureCenterX = trial.aperture_center_x; // The x-coordinate of center of the aperture on the screen, in pixels + var apertureCenterY = trial.aperture_center_y; // The y-coordinate of center of the aperture on the screen, in pixels + + + /* RDK type parameter + ** See Fig. 1 in Scase, Braddick, and Raymond (1996) for a visual depiction of these different signal selection rules and noise types + + ------------------- + SUMMARY: + + Signal Selection rule: + -Same: Each dot is designated to be either a coherent dot (signal) or incoherent dot (noise) and will remain so throughout all frames in the display. Coherent dots will always move in the direction of coherent motion in all frames. + -Different: Each dot can be either a coherent dot (signal) or incoherent dot (noise) and will be designated randomly (weighted based on the coherence level) at each frame. Only the dots that are designated to be coherent dots will move in the direction of coherent motion, but only in that frame. In the next frame, each dot will be designated randomly again on whether it is a coherent or incoherent dot. + + Noise Type: + -Random position: The incoherent dots appear in a random location in the aperture in each frame + -Random walk: The incoherent dots will move in a random direction (designated randomly in each frame) in each frame. + -Random direction: Each incoherent dot has its own alternative direction of motion (designated randomly at the beginning of the trial), and moves in that direction in each frame. + + ------------------- + + 1 - same && random position + 2 - same && random walk + 3 - same && random direction + 4 - different && random position + 5 - different && random walk + 6 - different && random direction */ + + var RDK = trial.RDK_type; + + + /* + Shape of aperture + 1 - Circle + 2 - Ellipse + 3 - Square + 4 - Rectangle + */ + var apertureType = trial.aperture_type; + + /* + Out of Bounds Decision + How we reinsert a dot that has moved outside the edges of the aperture: + 1 - Randomly appear anywhere in the aperture + 2 - Appear on the opposite edge of the aperture (Random if square or rectangle, reflected about origin in circle and ellipse) + */ + var reinsertType = trial.reinsert_type; + + //Fixation Cross Parameters + var fixationCross = trial.fixation_cross; //To display or not to display the cross + var fixationCrossWidth = trial.fixation_cross_width; //The width of the fixation cross in pixels + var fixationCrossHeight = trial.fixation_cross_height; //The height of the fixation cross in pixels + var fixationCrossColor = trial.fixation_cross_color; //The color of the fixation cross + var fixationCrossThickness = trial.fixation_cross_thickness; //The thickness of the fixation cross, must be positive number above 1 + + //Border Parameters + var border = trial.border; //To display or not to display the border + var borderThickness = trial.border_thickness; //The width of the border in pixels + var borderColor = trial.border_color; //The color of the border + + + + //-------------------------------------- + //----------SET PARAMETERS END---------- + //-------------------------------------- + + //--------Set up Canvas begin------- + + //Create a canvas element and append it to the DOM + var canvas = document.createElement("canvas"); + display_element.appendChild(canvas); + + + //The document body IS 'display_element' (i.e. .... ) + var body = document.getElementsByClassName("jspsych-display-element")[0]; + + //Save the current settings to be restored later + var originalMargin = body.style.margin; + var originalPadding = body.style.padding; + var originalBackgroundColor = body.style.backgroundColor; + + //Remove the margins and paddings of the display_element + body.style.margin = 0; + body.style.padding = 0; + body.style.backgroundColor = backgroundColor; //Match the background of the display element to the background color of the canvas so that the removal of the canvas at the end of the trial is not noticed + + //Remove the margins and padding of the canvas + canvas.style.margin = 0; + canvas.style.padding = 0; + // use absolute positioning in top left corner to get rid of scroll bars + canvas.style.position = 'absolute'; + canvas.style.top = 0; + canvas.style.left = 0; + + //Get the context of the canvas so that it can be painted on. + var ctx = canvas.getContext("2d"); + + //Declare variables for width and height, and also set the canvas width and height to the window width and height + var canvasWidth = canvas.width = window.innerWidth; + var canvasHeight = canvas.height = window.innerHeight; + + //Set the canvas background color + canvas.style.backgroundColor = backgroundColor; + + //--------Set up Canvas end------- + + + + //--------RDK variables and function calls begin-------- + + //This is the main part of the trial that makes everything run + + //Global variable for the current aperture number + var currentApertureNumber; + + //3D Array to hold the dots (1st D is Apertures, 2nd D is Sets, 3rd D is Dots) + var dotArray3d = []; + + //Variables for different apertures (initialized in setUpMultipleApertures function below) + var nDotsArray; + var nSetsArray; + var coherentDirectionArray; + var coherenceArray; + var oppositeCoherenceArray; + var dotRadiusArray; + var dotLifeArray; + var moveDistanceArray; + var apertureWidthArray; + var apertureHeightArray; + var dotColorArray; + var apertureCenterXArray; + var apertureCenterYArray; + + // Set up multiple apertures + setUpMultipleApertures(); + + //Declare aperture parameters for initialization based on shape (used in initializeApertureDimensions function below) + var horizontalAxis; + var verticalAxis; + + //Calculate the x and y jump sizes for coherent dots + var coherentJumpSizeX; + var coherentJumpSizeY; + + //Calculate the number of coherent, opposite coherent, and incoherent dots + var nCoherentDots; + var nOppositeCoherentDots; + var nIncoherentDots; + + //Make the array of arrays containing dot objects + var dotArray2d; + + var dotArray; //Declare a global variable to hold the current array + var currentSetArray; //Declare and initialize a global variable to cycle through the dot arrays + + + //Initialize stopping condition for animateDotMotion function that runs in a loop + var stopDotMotion = false; + + //Variable to control the frame rate, to ensure that the first frame is skipped because it follows a different timing + var firstFrame = true; //Used to skip the first frame in animate function below (in animateDotMotion function) + + //Variable to start the timer when the time comes + var timerHasStarted = false; + + //Initialize object to store the response data. Default values of -1 are used if the trial times out and the subject has not pressed a valid key + var response = { + rt: -1, + key: -1 + } + + //Declare a global timeout ID to be initialized below in animateDotMotion function and to be used in after_response function + var timeoutID; + + //Declare global variable to be defined in startKeyboardListener function and to be used in end_trial function + var keyboardListener; + + //Declare global variable to store the frame rate of the trial + var frameRate = []; //How often the monitor refreshes, in ms. Currently an array to store all the intervals. Will be converted into a single number (the average) in end_trial function. + + //variable to store how many frames were presented. + var numberOfFrames = 0; + + //This runs the dot motion simulation, updating it according to the frame refresh rate of the screen. + animateDotMotion(); + + + //--------RDK variables and function calls end-------- + + + + //------------------------------------- + //-----------FUNCTIONS BEGIN----------- + //------------------------------------- + + //----JsPsych Functions Begin---- + + + //Function to start the keyboard listener + function startKeyboardListener(){ + //Start the response listener if there are choices for keys + if (trial.choices != jsPsych.NO_KEYS) { + //Create the keyboard listener to listen for subjects' key response + keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, //Function to call once the subject presses a valid key + valid_responses: trial.choices, //The keys that will be considered a valid response and cause the callback function to be called + rt_method: 'performance', //The type of method to record timing information. + persist: false, //If set to false, keyboard listener will only trigger the first time a valid key is pressed. If set to true, it has to be explicitly cancelled by the cancelKeyboardResponse plugin API. + allow_held_key: false //Only register the key once, after this getKeyboardResponse function is called. (Check JsPsych docs for better info under 'jsPsych.pluginAPI.getKeyboardResponse'). + }); + } + } + + //Function to end the trial proper + function end_trial() { + + //Stop the dot motion animation + stopDotMotion = true; + + //Store the number of frames + numberOfFrames = frameRate.length; + + //Variable to store the frame rate array + var frameRateArray = frameRate; + + //Calculate the average frame rate + if(frameRate.length > 0){//Check to make sure that the array is not empty + frameRate = frameRate.reduce((total,current) => total + current)/frameRate.length; //Sum up all the elements in the array + }else{ + frameRate = 0; //Set to zero if the subject presses an answer before a frame is shown (i.e. if frameRate is an empty array) + } + + //Kill the keyboard listener if keyboardListener has been defined + if (typeof keyboardListener !== 'undefined') { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener); + } + + //Place all the data to be saved from this trial in one data object + var trial_data = { + rt: response.rt, //The response time + response: response.key, //The key that the subject pressed + correct: correctOrNot(), //If the subject response was correct + choices: trial.choices, //The set of valid keys + correct_choice: trial.correct_choice, //The correct choice + trial_duration: trial.trial_duration, //The trial duration + response_ends_trial: trial.response_ends_trial, //If the response ends the trial + number_of_apertures: trial.number_of_apertures, + number_of_dots: trial.number_of_dots, + number_of_sets: trial.number_of_sets, + coherent_direction: trial.coherent_direction, + coherence: trial.coherence, + opposite_coherence: trial.opposite_coherence, + dot_radius: trial.dot_radius, + dot_life: trial.dot_life, + move_distance: trial.move_distance, + aperture_width: trial.aperture_width, + aperture_height: trial.aperture_height, + dot_color: trial.dot_color, + background_color: trial.background_color, + RDK_type: trial.RDK_type, + aperture_type: trial.aperture_type, + reinsert_type: trial.reinsert_type, + frame_rate: frameRate, //The average frame rate for the trial + frame_rate_array: frameRateArray, //The array of ms per frame in this trial + number_of_frames: numberOfFrames, //The number of frames in this trial + aperture_center_x: trial.aperture_center_x, + aperture_center_y: trial.aperture_center_y, + fixation_cross: trial.fixation_cross, + fixation_cross_width: trial.fixation_cross_width, + fixation_cross_height: trial.fixation_cross_height, + fixation_cross_color: trial.fixation_cross_color, + fixation_cross_thickness: trial.fixation_cross_thickness, + border: trial.border, + border_thickness: trial.border_thickness, + border_color: trial.border_color, + canvas_width: canvasWidth, + canvas_height: canvasHeight + } + + //Remove the canvas as the child of the display_element element + display_element.innerHTML=''; + + //Restore the settings to JsPsych defaults + body.style.margin = originalMargin; + body.style.padding = originalPadding; + body.style.backgroundColor = originalBackgroundColor + + //End this trial and move on to the next trial + jsPsych.finishTrial(trial_data); + + } //End of end_trial + + //Function to record the first response by the subject + function after_response(info) { + + //If the response has not been recorded, record it + if (response.key == -1) { + response = info; //Replace the response object created above + } + + //If the parameter is set such that the response ends the trial, then kill the timeout and end the trial + if (trial.response_ends_trial) { + window.clearTimeout(timeoutID); + end_trial(); + } + + } //End of after_response + + //Function that determines if the response is correct + function correctOrNot(){ + + //Check that the correct_choice has been defined + if(typeof trial.correct_choice !== 'undefined'){ + //If the correct_choice variable holds an array + if(trial.correct_choice.constructor === Array){ //If it is an array + //If the elements are characters + if(typeof trial.correct_choice[0] === 'string' || trial.correct_choice[0] instanceof String){ + var key_in_choices = trial.correct_choice.every(function(x) { + return jsPsych.pluginAPI.compareKeys(x,response.key); + }); + return key_in_choices; //If the response is included in the correct_choice array, return true. Else, return false. + } + //Else if the elements are numbers (javascript character codes) + else if (typeof trial.correct_choice[0] === 'number'){ + console.error('Error in RDK plugin: correct_choice value must be a string.'); + } + } + //Else compare the char with the response key + else{ + //If the element is a character + if(typeof trial.correct_choice === 'string' || trial.correct_choice instanceof String){ + //Return true if the user's response matches the correct answer. Return false otherwise. + return jsPsych.pluginAPI.compareKeys(response.key, trial.correct_choice); + } + //Else if the element is a number (javascript character codes) + else if (typeof trial.correct_choice === 'number'){ + console.error('Error in RDK plugin: correct_choice value must be a string.'); + } + } + } + } + + //----JsPsych Functions End---- + + //----RDK Functions Begin---- + + //Set up the variables for the apertures + function setUpMultipleApertures(){ + nDotsArray = setParameter(nDots); + nSetsArray = setParameter(nSets); + coherentDirectionArray = setParameter(coherentDirection); + coherenceArray = setParameter(coherence); + oppositeCoherenceArray = setParameter(oppositeCoherence); + dotRadiusArray = setParameter(dotRadius); + dotLifeArray = setParameter(dotLife); + moveDistanceArray = setParameter(moveDistance); + apertureWidthArray = setParameter(apertureWidth); + apertureHeightArray = setParameter(apertureHeight); + dotColorArray = setParameter(dotColor); + apertureCenterXArray = setParameter(apertureCenterX); + apertureCenterYArray = setParameter(apertureCenterY); + RDKArray = setParameter(RDK); + apertureTypeArray = setParameter(apertureType); + reinsertTypeArray = setParameter(reinsertType); + fixationCrossArray = setParameter(fixationCross); + fixationCrossWidthArray = setParameter(fixationCrossWidth); + fixationCrossHeightArray = setParameter(fixationCrossHeight); + fixationCrossColorArray = setParameter(fixationCrossColor); + fixationCrossThicknessArray = setParameter(fixationCrossThickness); + borderArray = setParameter(border); + borderThicknessArray = setParameter(borderThickness); + borderColorArray = setParameter(borderColor); + + currentSetArray = setParameter(0); //Always starts at zero + + + //Loop through the number of apertures to make the dots + for(currentApertureNumber = 0; currentApertureNumber < nApertures; currentApertureNumber++){ + + //Initialize the parameters to make the 2d dot array (one for each aperture); + initializeCurrentApertureParameters(); + + //Make each 2d array and push it into the 3d array + dotArray3d.push(makeDotArray2d()); + } + } + + //Function to set the parameters of the array + function setParameter(originalVariable){ + //Check if it is an array and its length matches the aperture then return the original array + if(originalVariable.constructor === Array && originalVariable.length === nApertures){ + return originalVariable; + } + //Else if it is not an array, we make it an array with duplicate values + else if(originalVariable.constructor !== Array){ + + var tempArray = []; + + //Make a for loop and duplicate the values + for(var i = 0; i < nApertures; i++){ + tempArray.push(originalVariable); + } + return tempArray; + } + //Else if the array is not long enough, then print out that error message + else if(originalVariable.constructor === Array && originalVariable.length !== nApertures){ + console.error("If you have more than one aperture, please ensure that arrays that are passed in as parameters are the same length as the number of apertures. Else you can use a single value without the array"); + } + //Else print a generic error + else{ + console.error("A parameter is incorrectly set. Please ensure that the nApertures parameter is set to the correct value (if using more than one aperture), and all others parameters are set correctly."); + } + } + + //Function to set the global variables to the current aperture so that the correct dots are updated and drawn + function initializeCurrentApertureParameters(){ + + //Set the global variables to that relevant to the current aperture + nDots = nDotsArray[currentApertureNumber]; + nSets = nSetsArray[currentApertureNumber]; + coherentDirection = coherentDirectionArray[currentApertureNumber]; + coherence = coherenceArray[currentApertureNumber]; + oppositeCoherence = oppositeCoherenceArray[currentApertureNumber]; + dotRadius = dotRadiusArray[currentApertureNumber]; + dotLife = dotLifeArray[currentApertureNumber]; + moveDistance = moveDistanceArray[currentApertureNumber]; + apertureWidth = apertureWidthArray[currentApertureNumber]; + apertureHeight = apertureHeightArray[currentApertureNumber]; + dotColor = dotColorArray[currentApertureNumber]; + apertureCenterX = apertureCenterXArray[currentApertureNumber]; + apertureCenterY = apertureCenterYArray[currentApertureNumber]; + RDK = RDKArray[currentApertureNumber]; + apertureType = apertureTypeArray[currentApertureNumber]; + reinsertType = reinsertTypeArray[currentApertureNumber]; + fixationCross = fixationCrossArray[currentApertureNumber]; + fixationCrossWidth = fixationCrossWidthArray[currentApertureNumber]; + fixationCrossHeight = fixationCrossHeightArray[currentApertureNumber]; + fixationCrossColor = fixationCrossColorArray[currentApertureNumber]; + fixationCrossThickness = fixationCrossThicknessArray[currentApertureNumber]; + border = borderArray[currentApertureNumber]; + borderThickness = borderThicknessArray[currentApertureNumber]; + borderColor = borderColorArray[currentApertureNumber]; + + //Calculate the x and y jump sizes for coherent dots + coherentJumpSizeX = calculateCoherentJumpSizeX(coherentDirection); + coherentJumpSizeY = calculateCoherentJumpSizeY(coherentDirection); + + //Initialize the aperture parameters + initializeApertureDimensions(); + + //Calculate the number of coherent, opposite coherent, and incoherent dots + nCoherentDots = nDots * coherence; + nOppositeCoherentDots = nDots * oppositeCoherence; + nIncoherentDots = nDots - (nCoherentDots + nOppositeCoherentDots); + + //If the 3d array has been made, then choose the 2d array and the current set + dotArray2d = dotArray3d.length !==0 ? dotArray3d[currentApertureNumber] : undefined; + + }// End of initializeCurrentApertureParameters + + //Calculate coherent jump size in the x direction + function calculateCoherentJumpSizeX(coherentDirection) { + var angleInRadians = coherentDirection * Math.PI / 180; + return moveDistance * Math.cos(angleInRadians); + } + + //Calculate coherent jump size in the y direction + function calculateCoherentJumpSizeY(coherentDirection) { + var angleInRadians = -coherentDirection * Math.PI / 180; //Negative sign because the y-axis is flipped on screen + return moveDistance * Math.sin(angleInRadians); + } + + //Initialize the parameters for the aperture for further calculation + function initializeApertureDimensions() { + //For circle and square + if (apertureType == 1 || apertureType == 3) { + horizontalAxis = verticalAxis = apertureWidth/2; + } + //For ellipse and rectangle + else if (apertureType == 2 || apertureType == 4) { + horizontalAxis = apertureWidth / 2; + verticalAxis = apertureHeight / 2; + } + } + + //Make the 2d array, which is an array of array of dots + function makeDotArray2d() { + //Declare an array to hold the sets of dot arrays + var tempArray = [] + //Loop for each set of dot array + for (var i = 0; i < nSets; i++) { + tempArray.push(makeDotArray()); //Make a dot array and push it into the 2d array + } + + return tempArray; + } + + //Make the dot array + function makeDotArray() { + var tempArray = [] + for (var i = 0; i < nDots; i++) { + //Initialize a dot to be modified and inserted into the array + var dot = { + x: 0, //x coordinate + y: 0, //y coordinate + vx: 0, //coherent x jumpsize (if any) + vy: 0, //coherent y jumpsize (if any) + vx2: 0, //incoherent (random) x jumpsize (if any) + vy2: 0, //incoherent (random) y jumpsize (if any) + latestXMove: 0, //Stores the latest x move direction for the dot (to be used in reinsertOnOppositeEdge function below) + latestYMove: 0, //Stores the latest y move direction for the dot (to be used in reinsertOnOppositeEdge function below) + lifeCount: Math.floor(randomNumberBetween(0, dotLife)), //Counter for the dot's life. Updates every time it is shown in a frame + updateType: "" //String to determine how this dot is updated + }; + + //randomly set the x and y coordinates + dot = resetLocation(dot); + + //For the same && random position RDK type + if (RDK == 1) { + //For coherent dots + if (i < nCoherentDots) { + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "constant direction"; + } + //For opposite coherent dots + else if(i >= nCoherentDots && i < (nCoherentDots + nOppositeCoherentDots)){ + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "opposite direction"; + } + //For incoherent dots + else { + dot.updateType = "random position"; + } + } //End of RDK==1 + + //For the same && random walk RDK type + if (RDK == 2) { + //For coherent dots + if (i < nCoherentDots) { + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "constant direction"; + } + //For opposite coherent dots + else if(i >= nCoherentDots && i < (nCoherentDots + nOppositeCoherentDots)){ + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "opposite direction"; + } + //For incoherent dots + else { + dot.updateType = "random walk"; + } + } //End of RDK==2 + + //For the same && random direction RDK type + if (RDK == 3) { + //For coherent dots + if (i < nCoherentDots) { + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "constant direction"; + } + //For opposite coherent dots + else if(i >= nCoherentDots && i < (nCoherentDots + nOppositeCoherentDots)){ + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "opposite direction"; + } + //For incoherent dots + else { + setvx2vy2(dot); // Set dot.vx2 and dot.vy2 + dot.updateType = "random direction"; + } + } //End of RDK==3 + + //For the different && random position RDK type + if (RDK == 4) { + //For all dots + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "constant direction or opposite direction or random position"; + } //End of RDK==4 + + //For the different && random walk RDK type + if (RDK == 5) { + //For all dots + dot = setvxvy(dot); // Set dot.vx and dot.vy + dot.updateType = "constant direction or opposite direction or random walk"; + } //End of RDK==5 + + //For the different && random direction RDK type + if (RDK == 6) { + //For all dots + dot = setvxvy(dot); // Set dot.vx and dot.vy + //Each dot will have its own alternate direction of motion + setvx2vy2(dot); // Set dot.vx2 and dot.vy2 + dot.updateType = "constant direction or opposite direction or random direction"; + } //End of RDK==6 + + tempArray.push(dot); + } //End of for loop + return tempArray; + } + + //Function to update all the dots all the apertures and then draw them + function updateAndDraw(){ + + //Three for loops that do things in sequence: clear, update, and draw dots. + + // Clear all the current dots + for(currentApertureNumber = 0; currentApertureNumber < nApertures; currentApertureNumber++){ + + //Initialize the variables for each parameter + initializeCurrentApertureParameters(currentApertureNumber); + + //Clear the canvas by drawing over the current dots + clearDots(); + } + + // Update all the relevant dots + for(currentApertureNumber = 0; currentApertureNumber < nApertures; currentApertureNumber++){ + + //Initialize the variables for each parameter + initializeCurrentApertureParameters(currentApertureNumber); + + //Update the dots + updateDots(); + } + + // Draw all the relevant dots on the canvas + for(currentApertureNumber = 0; currentApertureNumber < nApertures; currentApertureNumber++){ + + //Initialize the variables for each parameter + initializeCurrentApertureParameters(currentApertureNumber); + + //Draw on the canvas + draw(); + } + } + + //Function that clears the dots on the canvas by drawing over it with the color of the baclground + function clearDots(){ + + //Load in the current set of dot array for easy handling + var dotArray = dotArray2d[currentSetArray[currentApertureNumber]]; + + //Loop through the dots one by one and draw them + for (var i = 0; i < nDots; i++) { + dot = dotArray[i]; + ctx.beginPath(); + ctx.arc(dot.x, dot.y, dotRadius+1, 0, Math.PI * 2); + ctx.fillStyle = backgroundColor; + ctx.fill(); + } + } + + //Draw the dots on the canvas after they're updated + function draw() { + + //Load in the current set of dot array for easy handling + var dotArray = dotArray2d[currentSetArray[currentApertureNumber]]; + + //Loop through the dots one by one and draw them + for (var i = 0; i < nDots; i++) { + dot = dotArray[i]; + ctx.beginPath(); + ctx.arc(dot.x, dot.y, dotRadius, 0, Math.PI * 2); + ctx.fillStyle = dotColor; + ctx.fill(); + } + + //Draw the fixation cross if we want it + if(fixationCross === true){ + //Horizontal line + ctx.beginPath(); + ctx.lineWidth = fixationCrossThickness; + ctx.moveTo(canvasWidth/2 - fixationCrossWidth, canvasHeight/2); + ctx.lineTo(canvasWidth/2 + fixationCrossWidth, canvasHeight/2); + ctx.strokeStyle = fixationCrossColor; + ctx.stroke(); + + //Vertical line + ctx.beginPath(); + ctx.lineWidth = fixationCrossThickness; + ctx.moveTo(canvasWidth/2, canvasHeight/2 - fixationCrossHeight); + ctx.lineTo(canvasWidth/2, canvasHeight/2 + fixationCrossHeight); + ctx.strokeStyle = fixationCrossColor; + ctx.stroke(); + } + + //Draw the border if we want it + if(border === true){ + + //For circle and ellipse + if(apertureType === 1 || apertureType === 2){ + ctx.lineWidth = borderThickness; + ctx.strokeStyle = borderColor; + ctx.beginPath(); + ctx.ellipse(apertureCenterX, apertureCenterY, horizontalAxis+(borderThickness/2), verticalAxis+(borderThickness/2), 0, 0, Math.PI*2); + ctx.stroke(); + }//End of if circle or ellipse + + //For square and rectangle + if(apertureType === 3 || apertureType === 4){ + ctx.lineWidth = borderThickness; + ctx.strokeStyle = borderColor; + ctx.strokeRect(apertureCenterX-horizontalAxis-(borderThickness/2), apertureCenterY-verticalAxis-(borderThickness/2), (horizontalAxis*2)+borderThickness, (verticalAxis*2)+borderThickness); + }//End of if square or + + }//End of if border === true + + }//End of draw + + //Update the dots with their new location + function updateDots() { + + //Cycle through to the next set of dots + if (currentSetArray[currentApertureNumber] == nSets - 1) { + currentSetArray[currentApertureNumber] = 0; + } else { + currentSetArray[currentApertureNumber] = currentSetArray[currentApertureNumber] + 1; + } + + //Load in the current set of dot array for easy handling + var dotArray = dotArray2d[currentSetArray[currentApertureNumber]]; + + //Load in the current set of dot array for easy handling + //dotArray = dotArray2d[currentSetArray[currentApertureNumber]]; //Global variable, so the draw function also uses this array + + //Loop through the dots one by one and update them accordingly + for (var i = 0; i < nDots; i++) { + var dot = dotArray[i]; //Load the current dot into the variable for easy handling + + //Generate a random value + var randomValue = Math.random(); + + //Update based on the dot's update type + if (dot.updateType == "constant direction") { + dot = constantDirectionUpdate(dot); + } else if (dot.updateType == "opposite direction") { + dot = oppositeDirectionUpdate(dot); + } else if (dot.updateType == "random position") { + dot = resetLocation(dot); + } else if (dot.updateType == "random walk") { + dot = randomWalkUpdate(dot); + } else if (dot.updateType == "random direction") { + dot = randomDirectionUpdate(dot); + } else if (dot.updateType == "constant direction or opposite direction or random position") { + + //Randomly select if the dot goes in a constant direction or random position, weighted based on the coherence level + if (randomValue < coherence) { + dot = constantDirectionUpdate(dot); + } else if(randomValue >= coherence && randomValue < (coherence + oppositeCoherence)){ + dot = oppositeDirectionUpdate(dot); + } else { + dot = resetLocation(dot); + } + } else if (dot.updateType == "constant direction or opposite direction or random walk") { + //Randomly select if the dot goes in a constant direction or random walk, weighted based on the coherence level + if (randomValue < coherence) { + dot = constantDirectionUpdate(dot); + } else if(randomValue >= coherence && randomValue < (coherence + oppositeCoherence)){ + dot = oppositeDirectionUpdate(dot); + } else { + dot = randomWalkUpdate(dot); + } + } else if (dot.updateType == "constant direction or opposite direction or random direction") { + //Randomly select if the dot goes in a constant direction or random direction, weighted based on the coherence level + if (randomValue < coherence) { + dot = constantDirectionUpdate(dot); + } else if(randomValue >= coherence && randomValue < (coherence + oppositeCoherence)){ + dot = oppositeDirectionUpdate(dot); + } else { + dot = randomDirectionUpdate(dot); + } + }//End of if dot.updateType == ... + + //Increment the life count + dot.lifeCount++; + + //Check if out of bounds or if life ended + if (lifeEnded(dot)) { + dot = resetLocation(dot); + } + + //If it goes out of bounds, do what is necessary (reinsert randomly or reinsert on the opposite edge) based on the parameter chosen + if (outOfBounds(dot)) { + switch (reinsertType) { + case 1: + dot = resetLocation(dot); + break; + case 2: + dot = reinsertOnOppositeEdge(dot); + break; + } //End of switch statement + } //End of if + + } //End of for loop + } //End of updateDots function + + //Function to check if dot life has ended + function lifeEnded(dot) { + //If we want infinite dot life + if (dotLife < 0) { + dot.lifeCount = 0; //resetting to zero to save memory. Otherwise it might increment to huge numbers. + return false; + } + //Else if the dot's life has reached its end + else if (dot.lifeCount >= dotLife) { + dot.lifeCount = 0; + return true; + } + //Else the dot's life has not reached its end + else { + return false; + } + } + + //Function to check if dot is out of bounds + function outOfBounds(dot) { + //For circle and ellipse + if (apertureType == 1 || apertureType == 2) { + if (dot.x < xValueNegative(dot.y) || dot.x > xValuePositive(dot.y) || dot.y < yValueNegative(dot.x) || dot.y > yValuePositive(dot.x)) { + return true; + } else { + return false; + } + } + //For square and rectangle + if (apertureType == 3 || apertureType == 4) { + if (dot.x < (apertureCenterX) - horizontalAxis || dot.x > (apertureCenterX) + horizontalAxis || dot.y < (apertureCenterY) - verticalAxis || dot.y > (apertureCenterY) + verticalAxis) { + return true; + } else { + return false; + } + } + + } + + //Set the vx and vy for the dot to the coherent jump sizes of the X and Y directions + function setvxvy(dot) { + dot.vx = coherentJumpSizeX; + dot.vy = coherentJumpSizeY; + return dot; + } + + //Set the vx2 and vy2 based on a random angle + function setvx2vy2(dot) { + //Generate a random angle of movement + var theta = randomNumberBetween(-Math.PI, Math.PI); + //Update properties vx2 and vy2 with the alternate directions + dot.vx2 = Math.cos(theta) * moveDistance; + dot.vy2 = -Math.sin(theta) * moveDistance; + return dot; + } + + //Updates the x and y coordinates by moving it in the x and y coherent directions + function constantDirectionUpdate(dot) { + dot.x += dot.vx; + dot.y += dot.vy; + dot.latestXMove = dot.vx; + dot.latestYMove = dot.vy; + return dot; + } + + //Updates the x and y coordinates by moving it in the opposite x and y coherent directions + function oppositeDirectionUpdate(dot) { + dot.x -= dot.vx; + dot.y -= dot.vy; + dot.latestXMove = -dot.vx; + dot.latestYMove = -dot.vy; + return dot; + } + + //Creates a new angle to move towards and updates the x and y coordinates + function randomWalkUpdate(dot) { + //Generate a random angle of movement + var theta = randomNumberBetween(-Math.PI, Math.PI); + //Generate the movement from the angle + dot.latestXMove = Math.cos(theta) * moveDistance; + dot.latestYMove = -Math.sin(theta) * moveDistance; + //Update x and y coordinates with the new location + dot.x += dot.latestXMove; + dot.y += dot.latestYMove; + return dot; + } + + //Updates the x and y coordinates with the alternative move direction + function randomDirectionUpdate(dot) { + dot.x += dot.vx2; + dot.y += dot.vy2; + dot.latestXMove = dot.vx2; + dot.latestYMove = dot.vy2; + return dot; + } + + //Calculates a random position on the opposite edge to reinsert the dot + function reinsertOnOppositeEdge(dot) { + //If it is a circle or ellipse + if (apertureType == 1 || apertureType == 2) { + //Bring the dot back into the aperture by moving back one step + dot.x -= dot.latestXMove; + dot.y -= dot.latestYMove; + + //Move the dot to the position relative to the origin to be reflected about the origin + dot.x -= apertureCenterX; + dot.y -= apertureCenterY; + + //Reflect the dot about the origin + dot.x = -dot.x; + dot.y = -dot.y; + + //Move the dot back to the center of the screen + dot.x += apertureCenterX; + dot.y += apertureCenterY; + + } //End of if apertureType == 1 | == 2 + + //If it is a square or rectangle, re-insert on one of the opposite edges + if (apertureType == 3 || apertureType == 4) { + + /* The formula for calculating whether a dot appears from the vertical edge (left or right edges) is dependent on the direction of the dot and the ratio of the vertical and horizontal edge lengths. + E.g. + Aperture is 100 px high and 200px wide + Dot is moving 3 px in x direction and 4px in y direction + Weight on vertical edge (sides) = (100/(100+200)) * (|3| / (|3| + |4|)) = 1/7 + Weight on horizontal edge (top or bottom) = (200/(100+200)) * (|4| / (|3| + |4|)) = 8/21 + + The weights above are the ratios to one another. + E.g. (cont.) + Ratio (vertical edge : horizontal edge) == (1/7 : 8/21) + Total probability space = 1/7 + 8/21 = 11/21 + Probability that dot appears on vertical edge = (1/7)/(11/21) = 3/11 + Probability that dot appears on horizontal edge = (8/21)/(11/21) = 8/11 + */ + + //Get the absolute values of the latest X and Y moves and store them in variables for easy handling. + var absX = Math.abs(dot.latestXMove); + var absY = Math.abs(dot.latestYMove); + //Calculate the direction weights based on direction the dot was moving + var weightInXDirection = absX / (absX + absY); + var weightInYDirection = absY / (absX + absY); + //Calculate the weight of the edge the dot should appear from, based on direction of dot and ratio of the aperture edges + var weightOnVerticalEdge = (verticalAxis / (verticalAxis + horizontalAxis)) * weightInXDirection; + var weightOnHorizontalEdge = (horizontalAxis / (verticalAxis + horizontalAxis)) * weightInYDirection; + + + //Generate a bounded random number to determine if the dot should appear on the vertical edge or the horizontal edge + if (weightOnVerticalEdge > (weightOnHorizontalEdge + weightOnVerticalEdge) * Math.random()) { //If yes, appear on the left or right edge (vertical edge) + if (dot.latestXMove < 0) { //If dots move left, appear on right edge + dot.x = apertureCenterX + horizontalAxis; + dot.y = randomNumberBetween((apertureCenterY) - verticalAxis, (apertureCenterY) + verticalAxis); + } else { //Else dots move right, so they should appear on the left edge + dot.x = apertureCenterX - horizontalAxis; + dot.y = randomNumberBetween((apertureCenterY) - verticalAxis, (apertureCenterY) + verticalAxis); + } + } else { //Else appear on the top or bottom edge (horizontal edge) + if (dot.latestYMove < 0) { //If dots move upwards, then appear on bottom edge + dot.y = apertureCenterY + verticalAxis; + dot.x = randomNumberBetween((apertureCenterX) - horizontalAxis, (apertureCenterX) + horizontalAxis) + } else { //If dots move downwards, then appear on top edge + dot.y = apertureCenterY - verticalAxis; + dot.x = randomNumberBetween((apertureCenterX) - horizontalAxis, (apertureCenterX) + horizontalAxis) + } + } + } //End of apertureType == 3 + return dot; + } //End of reinsertOnOppositeEdge + + //Calculate the POSITIVE y value of a point on the edge of the ellipse given an x-value + function yValuePositive(x) { + var x = x - (apertureCenterX); //Bring it back to the (0,0) center to calculate accurately (ignore the y-coordinate because it is not necessary for calculation) + return verticalAxis * Math.sqrt(1 - (Math.pow(x, 2) / Math.pow(horizontalAxis, 2))) + apertureCenterY; //Calculated the positive y value and added apertureCenterY to recenter it on the screen + } + + //Calculate the NEGATIVE y value of a point on the edge of the ellipse given an x-value + function yValueNegative(x) { + var x = x - (apertureCenterX); //Bring it back to the (0,0) center to calculate accurately (ignore the y-coordinate because it is not necessary for calculation) + return -verticalAxis * Math.sqrt(1 - (Math.pow(x, 2) / Math.pow(horizontalAxis, 2))) + apertureCenterY; //Calculated the negative y value and added apertureCenterY to recenter it on the screen + } + + //Calculate the POSITIVE x value of a point on the edge of the ellipse given a y-value + function xValuePositive(y) { + var y = y - (apertureCenterY); //Bring it back to the (0,0) center to calculate accurately (ignore the x-coordinate because it is not necessary for calculation) + return horizontalAxis * Math.sqrt(1 - (Math.pow(y, 2) / Math.pow(verticalAxis, 2))) + apertureCenterX; //Calculated the positive x value and added apertureCenterX to recenter it on the screen + } + + //Calculate the NEGATIVE x value of a point on the edge of the ellipse given a y-value + function xValueNegative(y) { + var y = y - (apertureCenterY); //Bring it back to the (0,0) center to calculate accurately (ignore the x-coordinate because it is not necessary for calculation) + return -horizontalAxis * Math.sqrt(1 - (Math.pow(y, 2) / Math.pow(verticalAxis, 2))) + apertureCenterX; //Calculated the negative x value and added apertureCenterX to recenter it on the screen + } + + //Calculate a random x and y coordinate in the ellipse + function resetLocation(dot) { + + //For circle and ellipse + if (apertureType == 1 || apertureType == 2) { + var phi = randomNumberBetween(-Math.PI, Math.PI); + var rho = Math.random(); + + x = Math.sqrt(rho) * Math.cos(phi); + y = Math.sqrt(rho) * Math.sin(phi); + + x = x * horizontalAxis + apertureCenterX; + y = y * verticalAxis + apertureCenterY; + + dot.x = x; + dot.y = y; + } + //For square and rectangle + else if (apertureType == 3 || apertureType == 4) { + dot.x = randomNumberBetween((apertureCenterX) - horizontalAxis, (apertureCenterX) + horizontalAxis); //Between the left and right edges of the square / rectangle + dot.y = randomNumberBetween((apertureCenterY) - verticalAxis, (apertureCenterY) + verticalAxis); //Between the top and bottom edges of the square / rectangle + } + + return dot; + } + + //Generates a random number (with decimals) between 2 values + function randomNumberBetween(lowerBound, upperBound) { + return lowerBound + Math.random() * (upperBound - lowerBound); + } + + //Function to make the dots move on the canvas + function animateDotMotion() { + //frameRequestID saves a long integer that is the ID of this frame request. The ID is then used to terminate the request below. + var frameRequestID = window.requestAnimationFrame(animate); + + //Start to listen to subject's key responses + startKeyboardListener(); + + //Delare a timestamp + var previousTimestamp; + + function animate() { + //If stopping condition has been reached, then stop the animation + if (stopDotMotion) { + window.cancelAnimationFrame(frameRequestID); //Cancels the frame request + } + //Else continue with another frame request + else { + frameRequestID = window.requestAnimationFrame(animate); //Calls for another frame request + + //If the timer has not been started and it is set, then start the timer + if ( (!timerHasStarted) && (trial.trial_duration > 0) ){ + //If the trial duration is set, then set a timer to count down and call the end_trial function when the time is up + //(If the subject did not press a valid keyboard response within the trial duration, then this will end the trial) + timeoutID = window.setTimeout(end_trial,trial.trial_duration); //This timeoutID is then used to cancel the timeout should the subject press a valid key + //The timer has started, so we set the variable to true so it does not start more timers + timerHasStarted = true; + } + + updateAndDraw(); //Update and draw each of the dots in their respective apertures + + //If this is before the first frame, then start the timestamp + if(previousTimestamp === undefined){ + previousTimestamp = performance.now(); + } + //Else calculate the time and push it into the array + else{ + var currentTimeStamp = performance.now(); //Variable to hold current timestamp + frameRate.push(currentTimeStamp - previousTimestamp); //Push the interval into the frameRate array + previousTimestamp = currentTimeStamp; //Reset the timestamp + } + } + } + } + + //----RDK Functions End---- + + //----General Functions Begin//---- + + //Function to assign the default values for the staircase parameters + function assignParameterValue(argument, defaultValue){ + return typeof argument !== 'undefined' ? argument : defaultValue; + } + + //----General Functions End//---- + + + //------------------------------------- + //-----------FUNCTIONS END------------- + //------------------------------------- + + + }; // END OF TRIAL + + //Return the plugin object which contains the trial + return plugin; +})(); diff --git a/scripts/plugins/jspsych-reconstruction.js b/scripts/plugins/jspsych-reconstruction.js new file mode 100644 index 0000000..fb0eabd --- /dev/null +++ b/scripts/plugins/jspsych-reconstruction.js @@ -0,0 +1,134 @@ +/** + * jspsych-reconstruction + * a jspsych plugin for a reconstruction task where the subject recreates + * a stimulus from memory + * + * Josh de Leeuw + * + * documentation: docs.jspsych.org + * + */ + + +jsPsych.plugins['reconstruction'] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'reconstruction', + description: '', + parameters: { + stim_function: { + type: jsPsych.plugins.parameterType.FUNCTION, + pretty_name: 'Stimulus function', + default: undefined, + description: 'A function with a single parameter that returns an HTML-formatted string representing the stimulus.' + }, + starting_value: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Starting value', + default: 0.5, + description: 'The starting value of the stimulus parameter.' + }, + step_size: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Step size', + default: 0.05, + description: 'The change in the stimulus parameter caused by pressing one of the modification keys.' + }, + key_increase: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key increase', + default: 'h', + description: 'The key to press for increasing the parameter value.' + }, + key_decrease: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Key decrease', + default: 'g', + description: 'The key to press for decreasing the parameter value.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + description: 'The text that appears on the button to finish the trial.' + } + } + } + + plugin.trial = function(display_element, trial) { + + // current param level + var param = trial.starting_value; + + // set-up key listeners + var after_response = function(info) { + + //console.log('fire'); + + var key_i = trial.key_increase; + var key_d = trial.key_decrease; + + // get new param value + if (jsPsych.pluginAPI.compareKeys(info.key, key_i)) { + param = param + trial.step_size; + } else if (jsPsych.pluginAPI.compareKeys(info.key, key_d)) { + param = param - trial.step_size; + } + param = Math.max(Math.min(1, param), 0); + + // refresh the display + draw(param); + } + + // listen for responses + var key_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: [trial.key_increase, trial.key_decrease], + rt_method: 'performance', + persist: true, + allow_held_key: true + }); + // draw first iteration + draw(param); + + function draw(param) { + + //console.log(param); + + display_element.innerHTML = '
'+trial.stim_function(param)+'
'; + + // add submit button + display_element.innerHTML += ''; + + display_element.querySelector('#jspsych-reconstruction-next').addEventListener('click', endTrial); + } + + function endTrial() { + // measure response time + var endTime =performance.now(); + var response_time = endTime - startTime; + + // clear keyboard response + jsPsych.pluginAPI.cancelKeyboardResponse(key_listener); + + // save data + var trial_data = { + rt: response_time, + final_value: param, + start_value: trial.starting_value + }; + + display_element.innerHTML = ''; + + // next trial + jsPsych.finishTrial(trial_data); + } + + var startTime = performance.now(); + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-resize.js b/scripts/plugins/jspsych-resize.js new file mode 100644 index 0000000..37c5c33 --- /dev/null +++ b/scripts/plugins/jspsych-resize.js @@ -0,0 +1,166 @@ +/** +* jspsych-resize +* Steve Chao +* +* plugin for controlling the real world size of the display +* +* documentation: docs.jspsych.org +* +**/ + +jsPsych.plugins["resize"] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'resize', + description: '', + parameters: { + item_height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Item height', + default: 1, + description: 'The height of the item to be measured.' + }, + item_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Item width', + default: 1, + description: 'The width of the item to be measured.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'The content displayed below the resizable box and above the button.' + }, + pixels_per_unit: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Pixels per unit', + default: 100, + description: 'After the scaling factor is applied, this many pixels will equal one unit of measurement.' + }, + starting_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Starting size', + default: 100, + description: 'The initial size of the box, in pixels, along the larget dimension.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + description: 'Label to display on the button to complete calibration.' + }, + } + } + + plugin.trial = function(display_element, trial) { + + var aspect_ratio = trial.item_width / trial.item_height; + + // variables to determine div size + if(trial.item_width >= trial.item_height){ + var start_div_width = trial.starting_size; + var start_div_height = Math.round(trial.starting_size / aspect_ratio); + } else { + var start_div_height = trial.starting_size; + var start_div_width = Math.round(trial.starting_size * aspect_ratio); + } + + // create html for display + var html ='
'; + html += '
'; + html += '
'; + if (trial.prompt !== null){ + html += trial.prompt; + } + html += ''+trial.button_label+''; + + // render + display_element.innerHTML = html; + + // listens for the click + document.getElementById("jspsych-resize-btn").addEventListener('click', function() { + scale(); + end_trial(); + }); + + var dragging = false; + var origin_x, origin_y; + var cx, cy; + + var mousedownevent = function(e){ + e.preventDefault(); + dragging = true; + origin_x = e.pageX; + origin_y = e.pageY; + cx = parseInt(scale_div.style.width); + cy = parseInt(scale_div.style.height); + } + + display_element.querySelector('#jspsych-resize-handle').addEventListener('mousedown', mousedownevent); + + var mouseupevent = function(e){ + dragging = false; + } + + document.addEventListener('mouseup', mouseupevent); + + var scale_div = display_element.querySelector('#jspsych-resize-div'); + + var resizeevent = function(e){ + if(dragging){ + var dx = (e.pageX - origin_x); + var dy = (e.pageY - origin_y); + + if(Math.abs(dx) >= Math.abs(dy)){ + scale_div.style.width = Math.round(Math.max(20, cx+dx*2)) + "px"; + scale_div.style.height = Math.round(Math.max(20, cx+dx*2) / aspect_ratio ) + "px"; + } else { + scale_div.style.height = Math.round(Math.max(20, cy+dy*2)) + "px"; + scale_div.style.width = Math.round(aspect_ratio * Math.max(20, cy+dy*2)) + "px"; + } + } + } + + document.addEventListener('mousemove', resizeevent); + + // scales the stimulus + var scale_factor; + var final_height_px, final_width_px; + function scale() { + final_width_px = scale_div.offsetWidth; + //final_height_px = scale_div.offsetHeight; + + var pixels_unit_screen = final_width_px / trial.item_width; + + scale_factor = pixels_unit_screen / trial.pixels_per_unit; + document.getElementById("jspsych-content").style.transform = "scale(" + scale_factor + ")"; + }; + + + // function to end trial + function end_trial() { + + // clear document event listeners + document.removeEventListener('mousemove', resizeevent); + document.removeEventListener('mouseup', mouseupevent); + + // clear the screen + display_element.innerHTML = ''; + + // finishes trial + + var trial_data = { + final_height_px: final_height_px, + final_width_px: final_width_px, + scale_factor: scale_factor + } + + jsPsych.finishTrial(trial_data); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-same-different-html.js b/scripts/plugins/jspsych-same-different-html.js new file mode 100644 index 0000000..7b13e63 --- /dev/null +++ b/scripts/plugins/jspsych-same-different-html.js @@ -0,0 +1,168 @@ +/** + * jspsych-same-different + * Josh de Leeuw + * + * plugin for showing two stimuli sequentially and getting a same / different judgment + * + * documentation: docs.jspsych.org + * + */ + +jsPsych.plugins['same-different-html'] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'same-different-html', + description: '', + parameters: { + stimuli: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: 'Stimuli', + default: undefined, + array: true, + description: 'The HTML content to be displayed.' + }, + answer: { + type: jsPsych.plugins.parameterType.SELECT, + pretty_name: 'Answer', + options: ['same', 'different'], + default: undefined, + description: 'Either "same" or "different".' + }, + same_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Same key', + default: 'q', + description: '' + }, + different_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Different key', + default: 'p', + description: 'The key that subjects should press to indicate that the two stimuli are the same.' + }, + first_stim_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'First stimulus duration', + default: null, + description: 'How long to show the first stimulus for in milliseconds. If null, then the stimulus will remain on the screen until any keypress is made.' + }, + gap_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Gap duration', + default: 500, + description: 'How long to show a blank screen in between the two stimuli.' + }, + second_stim_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Second stimulus duration', + default: null, + description: 'How long to show the second stimulus for in milliseconds. If null, then the stimulus will remain on the screen until a valid response is made.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + } + } + } + + plugin.trial = function(display_element, trial) { + + display_element.innerHTML = '
'+trial.stimuli[0]+'
'; + + var first_stim_info; + if (trial.first_stim_duration > 0) { + jsPsych.pluginAPI.setTimeout(function() { + showBlankScreen(); + }, trial.first_stim_duration); + } else { + function afterKeyboardResponse(info) { + first_stim_info = info; + showBlankScreen(); + } + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: afterKeyboardResponse, + valid_responses: trial.advance_key, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + + function showBlankScreen() { + display_element.innerHTML = ''; + + jsPsych.pluginAPI.setTimeout(function() { + showSecondStim(); + }, trial.gap_duration); + } + + function showSecondStim() { + + var html = '
'+trial.stimuli[1]+'
'; + //show prompt here + if (trial.prompt !== null) { + html += trial.prompt; + } + display_element.innerHTML = html; + + if (trial.second_stim_duration > 0) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('.jspsych-same-different-stimulus').style.visibility = 'hidden'; + }, trial.second_stim_duration); + } + + + + var after_response = function(info) { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + var correct = false; + + var skey = trial.same_key; + var dkey = trial.different_key; + + if (jsPsych.pluginAPI.compareKeys(info.key, skey) && trial.answer == 'same') { + correct = true; + } + + if (jsPsych.pluginAPI.compareKeys(info.key, dkey) && trial.answer == 'different') { + correct = true; + } + + var trial_data = { + rt: info.rt, + answer: trial.answer, + correct: correct, + stimulus: [trial.stimuli[0], trial.stimuli[1]], + response: info.key + }; + if (first_stim_info) { + trial_data["rt_stim1"] = first_stim_info.rt; + trial_data["response_stim1"] = first_stim_info.key; + } + + display_element.innerHTML = ''; + + jsPsych.finishTrial(trial_data); + } + + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: [trial.same_key, trial.different_key], + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-same-different-image.js b/scripts/plugins/jspsych-same-different-image.js new file mode 100644 index 0000000..85a3a20 --- /dev/null +++ b/scripts/plugins/jspsych-same-different-image.js @@ -0,0 +1,169 @@ +/** + * jspsych-same-different + * Josh de Leeuw + * + * plugin for showing two stimuli sequentially and getting a same / different judgment + * + * documentation: docs.jspsych.org + * + */ + +jsPsych.plugins['same-different-image'] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('same-different-image', 'stimuli', 'image') + + plugin.info = { + name: 'same-different-image', + description: '', + parameters: { + stimuli: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimuli', + default: undefined, + array: true, + description: 'The images to be displayed.' + }, + answer: { + type: jsPsych.plugins.parameterType.SELECT, + pretty_name: 'Answer', + options: ['same', 'different'], + default: undefined, + description: 'Either "same" or "different".' + }, + same_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Same key', + default: 'q', + description: '' + }, + different_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Different key', + default: 'p', + description: 'The key that subjects should press to indicate that the two stimuli are the same.' + }, + first_stim_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'First stimulus duration', + default: null, + description: 'How long to show the first stimulus for in milliseconds. If null, then the stimulus will remain on the screen until any keypress is made.' + }, + gap_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Gap duration', + default: 500, + description: 'How long to show a blank screen in between the two stimuli.' + }, + second_stim_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Second stimulus duration', + default: null, + description: 'How long to show the second stimulus for in milliseconds. If null, then the stimulus will remain on the screen until a valid response is made.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + } + } + } + + plugin.trial = function(display_element, trial) { + + display_element.innerHTML = ''; + + var first_stim_info; + if (trial.first_stim_duration > 0) { + jsPsych.pluginAPI.setTimeout(function() { + showBlankScreen(); + }, trial.first_stim_duration); + } else { + function afterKeyboardResponse(info) { + first_stim_info = info; + showBlankScreen(); + } + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: afterKeyboardResponse, + valid_responses: trial.advance_key, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + } + + function showBlankScreen() { + display_element.innerHTML = ''; + + jsPsych.pluginAPI.setTimeout(function() { + showSecondStim(); + }, trial.gap_duration); + } + + function showSecondStim() { + + var html = ''; + //show prompt + if (trial.prompt !== null) { + html += trial.prompt; + } + + display_element.innerHTML = html; + + if (trial.second_stim_duration > 0) { + jsPsych.pluginAPI.setTimeout(function() { + display_element.querySelector('.jspsych-same-different-stimulus').style.visibility = 'hidden'; + }, trial.second_stim_duration); + } + + var after_response = function(info) { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + var correct = false; + + var skey = trial.same_key; + var dkey = trial.different_key; + + if (jsPsych.pluginAPI.compareKeys(info.key,skey) && trial.answer == 'same') { + correct = true; + } + + if (jsPsych.pluginAPI.compareKeys(info.key, dkey) && trial.answer == 'different') { + correct = true; + } + + var trial_data = { + rt: info.rt, + answer: trial.answer, + correct: correct, + stimulus: [trial.stimuli[0], trial.stimuli[1]], + response: info.key + }; + if (first_stim_info) { + trial_data["rt_stim1"] = first_stim_info.rt; + trial_data["response_stim1"] = first_stim_info.key; + } + + display_element.innerHTML = ''; + + jsPsych.finishTrial(trial_data); + } + + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: [trial.same_key, trial.different_key], + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + + } + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-same-different.js b/scripts/plugins/jspsych-same-different.js deleted file mode 100644 index 05e63da..0000000 --- a/scripts/plugins/jspsych-same-different.js +++ /dev/null @@ -1,148 +0,0 @@ -/** - * jspsych-same-different - * Josh de Leeuw - * - * plugin for showing two stimuli sequentially and getting a same / different judgment - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-same-different - * - */ -(function($) { - jsPsych['same-different'] = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['data','answer']) - - var trials = new Array(params.stimuli.length); - for (var i = 0; i < trials.length; i++) { - trials[i] = {}; - trials[i].type = "same-different"; - trials[i].a_path = params.stimuli[i][0]; - trials[i].b_path = params.stimuli[i][1]; - trials[i].answer = params.answer[i]; - trials[i].same_key = params.same_key || 81; // default is 'q' - trials[i].different_key = params.different_key || 80; // default is 'p' - // timing parameters - trials[i].timing_first_stim = params.timing_first_stim || 1000; - trials[i].timing_second_stim = params.timing_second_stim || 1000; // if -1, then second stim is shown until response. - trials[i].timing_gap = params.timing_gap || 500; - trials[i].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial; - // optional parameters - trials[i].is_html = (typeof params.is_html === 'undefined') ? false : true; - trials[i].prompt = (typeof params.prompt === 'undefined') ? "" : params.prompt; - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data[i]; - } - return trials; - }; - - var sd_trial_complete = false; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - - switch (part) { - case 1: - sd_trial_complete = false; - // show image - if (!trial.is_html) { - display_element.append($('', { - src: trial.a_path, - "class": 'jspsych-same-different-stimulus' - })); - } - else { - display_element.append($('
', { - html: trial.a_path, - "class": 'jspsych-same-different-stimulus' - })); - } - setTimeout(function() { - plugin.trial(display_element, block, trial, part + 1); - }, trial.timing_first_stim); - break; - case 2: - $('.jspsych-same-different-stimulus').remove(); - setTimeout(function() { - plugin.trial(display_element, block, trial, part + 1); - }, trial.timing_gap); - break; - case 3: - if (!trial.is_html) { - display_element.append($('', { - src: trial.b_path, - "class": 'jspsych-same-different-stimulus', - id: 'jspsych-same-different-second-stimulus' - })); - } - else { - display_element.append($('
', { - html: trial.b_path, - "class": 'jspsych-same-different-stimulus', - id: 'jspsych-same-different-second-stimulus' - })); - } - - if (trial.timing_second_stim > 0) { - setTimeout(function() { - if (!sd_trial_complete) { - $("#jspsych-same-different-second-stimulus").css('visibility', 'hidden'); - } - }, trial.timing_second_stim); - } - - //show prompt here - if (trial.prompt !== "") { - display_element.append(trial.prompt); - } - - var after_response = function(info){ - - var correct = false; - - if(info.key == trial.same_key && trial.answer == 'same'){ - correct = true; - } - - if(info.key == trial.different_key && trial.answer == 'different'){ - correct = true; - } - - var trial_data = { - "trial_type": "same-different", - "trial_index": block.trial_idx, - "rt": info.rt, - "correct": correct, - "stimulus": trial.a_path, - "stimulus_2": trial.b_path, - "key_press": info.key - }; - block.writeData($.extend({}, trial_data, trial.data)); - - display_element.html(''); - - if(trial.timing_post_trial > 0) { - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } else { - block.next(); - } - } - - jsPsych.pluginAPI.getKeyboardResponse(after_response, [trial.same_key, trial.different_key], 'date', false); - - break; - } - }; - - return plugin; - })(); -})(jQuery); diff --git a/scripts/plugins/jspsych-serial-reaction-time-mouse.js b/scripts/plugins/jspsych-serial-reaction-time-mouse.js new file mode 100644 index 0000000..85eb3eb --- /dev/null +++ b/scripts/plugins/jspsych-serial-reaction-time-mouse.js @@ -0,0 +1,212 @@ +/** + * jspsych-serial-reaction-time + * Josh de Leeuw + * + * plugin for running a serial reaction time task + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["serial-reaction-time-mouse"] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'serial-reaction-time-mouse', + description: '', + parameters: { + target: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Target', + array: true, + default: undefined, + description: 'The location of the target. The array should be the [row, column] of the target.' + }, + grid: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Grid', + array: true, + default: [[1,1,1,1]], + description: 'This array represents the grid of boxes shown on the screen.' + }, + grid_square_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Grid square size', + default: 100, + description: 'The width and height in pixels of each square in the grid.' + }, + target_color: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Target color', + default: "#999", + description: 'The color of the target square.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, the trial ends after a mouse click.' + }, + pre_target_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Pre-target duration', + default: 0, + description: 'The number of milliseconds to display the grid before the target changes color.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show the trial' + }, + fade_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Fade duration', + default: null, + description: 'If a positive number, the target will progressively change color at the start of the trial, with the transition lasting this many milliseconds.' + }, + allow_nontarget_responses: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Allow nontarget response', + default: false, + description: 'If true, then user can make nontarget response.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus' + }, + } + } + + plugin.trial = function(display_element, trial) { + + var startTime = -1; + var response = { + rt: null, + row: null, + column: null + } + + // display stimulus + var stimulus = this.stimulus(trial.grid, trial.grid_square_size); + display_element.innerHTML = stimulus; + + + if(trial.pre_target_duration <= 0){ + showTarget(); + } else { + jsPsych.pluginAPI.setTimeout(function(){ + showTarget(); + }, trial.pre_target_duration); + } + + //show prompt if there is one + if (trial.prompt !== null) { + display_element.insertAdjacentHTML('beforeend', trial.prompt); + } + + function showTarget(){ + var resp_targets; + if(!trial.allow_nontarget_responses){ + resp_targets = [display_element.querySelector('#jspsych-serial-reaction-time-stimulus-cell-'+trial.target[0]+'-'+trial.target[1])] + } else { + resp_targets = display_element.querySelectorAll('.jspsych-serial-reaction-time-stimulus-cell'); + } + for(var i=0; i"; + for(var i=0; i -1){ + flat_choices.splice(flat_choices.indexOf(''),1); + } + + // display stimulus + var stimulus = this.stimulus(trial.grid, trial.grid_square_size); + display_element.innerHTML = stimulus; + + if(trial.pre_target_duration <= 0){ + showTarget(); + } else { + jsPsych.pluginAPI.setTimeout(function(){ + showTarget(); + }, trial.pre_target_duration); + } + + //show prompt if there is one + if (trial.prompt !== null) { + display_element.innerHTML += trial.prompt; + } + + var keyboardListener = {}; + + var response = { + rt: null, + key: false, + correct: false + } + + function showTarget(){ + if(trial.fade_duration == null){ + display_element.querySelector('#jspsych-serial-reaction-time-stimulus-cell-'+trial.target[0]+'-'+trial.target[1]).style.backgroundColor = trial.target_color; + } else { + display_element.querySelector('#jspsych-serial-reaction-time-stimulus-cell-'+trial.target[0]+'-'+trial.target[1]).style.transition = "background-color "+trial.fade_duration; + display_element.querySelector('#jspsych-serial-reaction-time-stimulus-cell-'+trial.target[0]+'-'+trial.target[1]).style.backgroundColor = trial.target_color; + } + + keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: flat_choices, + allow_held_key: false + }); + + if(trial.trial_duration > null){ + jsPsych.pluginAPI.setTimeout(showFeedback, trial.trial_duration); + } + + } + + function showFeedback() { + if(response.rt == null || trial.show_response_feedback == false){ + endTrial(); + } else { + var color = response.correct ? '#0f0' : '#f00'; + display_element.querySelector('#jspsych-serial-reaction-time-stimulus-cell-'+response.responseLoc[0]+'-'+response.responseLoc[1]).style.transition = ""; + display_element.querySelector('#jspsych-serial-reaction-time-stimulus-cell-'+response.responseLoc[0]+'-'+response.responseLoc[1]).style.backgroundColor = color; + jsPsych.pluginAPI.setTimeout(endTrial, trial.feedback_duration); + } + } + + function endTrial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // kill keyboard listeners + if (typeof keyboardListener !== 'undefined') { + jsPsych.pluginAPI.cancelKeyboardResponse(keyboardListener); + } + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + response: response.key, + correct: response.correct, + grid: trial.grid, + target: trial.target + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + + }; + + // function to handle responses by the subject + function after_response(info) { + + // only record first response + response = response.rt == null ? info : response; + + // check if the response is correct + var responseLoc = []; + for(var i=0; i"; + for(var i=0; i', { - "src": trial.a_path, - "id": 'jspsych_sim_stim' - })); - } - else { - display_element.append($('
', { - "html": trial.a_path, - "id": 'jspsych_sim_stim' - })); - } - - if (trial.show_response == "FIRST_STIMULUS") { - show_response_slider(display_element, trial, block); - } - - setTimeout(function() { - plugin.trial(display_element, block, trial, part + 1); - }, trial.timing_first_stim); - break; - - case 2: - - $('#jspsych_sim_stim').css('visibility', 'hidden'); - - setTimeout(function() { - plugin.trial(display_element, block, trial, part + 1); - }, trial.timing_image_gap); - break; - - case 3: - - if (!trial.is_html) { - $('#jspsych_sim_stim').attr('src', trial.b_path); - } - else { - $('#jspsych_sim_stim').html(trial.b_path); - } - - $('#jspsych_sim_stim').css('visibility', 'visible'); - - if (trial.show_response == "SECOND_STIMULUS") { - show_response_slider(display_element, trial, block); - } - - if (trial.timing_second_stim > 0) { - setTimeout(function() { - if (!sim_trial_complete) { - $("#jspsych_sim_stim").css('visibility', 'hidden'); - if (trial.show_response == "POST_STIMULUS") { - show_response_slider(display_element, trial, block); - } - } - }, trial.timing_second_stim); - } - - break; - } - }; - - function show_response_slider(display_element, trial, block) { - - var startTime = (new Date()).getTime(); - - // create slider - display_element.append($('
', { - "id": 'slider', - "class": 'sim' - })); - - $("#slider").slider({ - value: Math.ceil(trial.intervals / 2), - min: 1, - max: trial.intervals, - step: 1, - }); - - // show tick marks - if (trial.show_ticks) { - for (var j = 1; j < trial.intervals - 1; j++) { - $('#slider').append('
'); - } - - $('#slider .slidertickmark').each(function(index) { - var left = (index + 1) * (100 / (trial.intervals - 1)); - $(this).css({ - 'position': 'absolute', - 'left': left + '%', - 'width': '1px', - 'height': '100%', - 'background-color': '#222222' - }); - }); - } - - // create labels for slider - display_element.append($('
    ', { - "id": "sliderlabels", - "class": 'sliderlabels', - "css": { - "width": "100%", - "height": "3em", - "margin": "10px 0px 0px 0px", - "padding": "0px", - "display": "block", - "position": "relative" - } - })); - - for (var j = 0; j < trial.labels.length; j++) { - $("#sliderlabels").append('
  • ' + trial.labels[j] + '
  • '); - } - - // position labels to match slider intervals - var slider_width = $("#slider").width(); - var num_items = trial.labels.length; - var item_width = slider_width / num_items; - var spacing_interval = slider_width / (num_items - 1); - - $("#sliderlabels li").each(function(index) { - $(this).css({ - 'display': 'inline-block', - 'width': item_width + 'px', - 'margin': '0px', - 'padding': '0px', - 'text-align': 'center', - 'position': 'absolute', - 'left': (spacing_interval * index) - (item_width / 2) - }); - }); - - // create button - display_element.append($('
'; + } + + // add submit button + html += ''; + html += ''; + + // render + display_element.innerHTML = html; + + document.querySelector('form').addEventListener('submit', function(event) { + event.preventDefault(); + // measure response time + var endTime = performance.now(); + var response_time = endTime - startTime; + + // create object to hold responses + var question_data = {}; + for(var i=0; i'; + + // form element + var trial_form_id = _join(plugin_id_name, "form"); + display_element.innerHTML += '
'; + var trial_form = display_element.querySelector("#" + trial_form_id); + if ( !trial.autocomplete ) { + trial_form.setAttribute('autocomplete',"off"); + } + // show preamble text + var preamble_id_name = _join(plugin_id_name, 'preamble'); + if(trial.preamble !== null){ + trial_form.innerHTML += '
'+trial.preamble+'
'; + } + // generate question order. this is randomized here as opposed to randomizing the order of trial.questions + // so that the data are always associated with the same question regardless of order + var question_order = []; + for(var i=0; i
'; + + var question_selector = _join(plugin_id_selector, question_id); + + // add question text + display_element.querySelector(question_selector).innerHTML += '

' + question.prompt + '

'; + + // create option check boxes + for (var j = 0; j < question.options.length; j++) { + var option_id_name = _join(plugin_id_name, "option", question_id, j); + + // add check box container + display_element.querySelector(question_selector).innerHTML += '
'; + + // add label and question text + var form = document.getElementById(option_id_name) + var input_name = _join(plugin_id_name, 'response', question_id); + var input_id = _join(plugin_id_name, 'response', question_id, j); + var label = document.createElement('label'); + label.setAttribute('class', plugin_id_name+'-text'); + label.innerHTML = question.options[j]; + label.setAttribute('for', input_id) + + // create checkboxes + var input = document.createElement('input'); + input.setAttribute('type', "checkbox"); + input.setAttribute('name', input_name); + input.setAttribute('id', input_id); + input.setAttribute('value', question.options[j]) + form.appendChild(label) + label.insertBefore(input, label.firstChild) + } + } + // add submit button + trial_form.innerHTML += '
' + trial_form.innerHTML += ''; + + // validation check on the data first for custom validation handling + // then submit the form + display_element.querySelector('#jspsych-survey-multi-select-next').addEventListener('click', function(){ + for(var i=0; i', { - "id": 'jspsych-survey-text-' + i, - "class": 'jspsych-survey-text-question' - })); - - // add question text - $("#jspsych-survey-text-" + i).append('

' + trial.questions[i] + '

'); - - // add text box - $("#jspsych-survey-text-" + i).append(''); - } - - // add submit button - display_element.append($('
'; + } + + // add submit button + html += ''; + + html += '' + display_element.innerHTML = html; + + // backup in case autofocus doesn't work + display_element.querySelector('#input-'+question_order[0]).focus(); + + display_element.querySelector('#jspsych-survey-text-form').addEventListener('submit', function(e) { + e.preventDefault(); + // measure response time + var endTime = performance.now(); + var response_time = endTime - startTime; + + // create object to hold responses + var question_data = {}; + + for(var index=0; index < trial.questions.length; index++){ + var id = "Q" + index; + var q_element = document.querySelector('#jspsych-survey-text-'+index).querySelector('textarea, input'); + var val = q_element.value; + var name = q_element.attributes['data-name'].value; + if(name == ''){ + name = id; + } + var obje = {}; + obje[name] = val; + Object.assign(question_data, obje); + } + // save data + var trialdata = { + rt: response_time, + response: question_data + }; + + display_element.innerHTML = ''; + + // next trial + jsPsych.finishTrial(trialdata); + }); + + var startTime = performance.now(); + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-text.js b/scripts/plugins/jspsych-text.js deleted file mode 100644 index de2ee36..0000000 --- a/scripts/plugins/jspsych-text.js +++ /dev/null @@ -1,90 +0,0 @@ -/* jspsych-text.js - * Josh de Leeuw - * - * This plugin displays text (including HTML formatted strings) during the experiment. - * Use it to show instructions, provide performance feedback, etc... - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-text - * - * - */ - -(function($) { - jsPsych.text = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['text','data']); - - var trials = new Array(params.text.length); - for (var i = 0; i < trials.length; i++) { - trials[i] = {}; - trials[i].type = "text"; // must match plugin name - trials[i].text = params.text[i]; // text of all trials - trials[i].cont_key = params.cont_key || []; // keycode to press to advance screen, default is all keys. - trials[i].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 0 : params.timing_post_trial; - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data[i]; - } - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - // set the HTML of the display target to replaced_text. - display_element.html(trial.text); - - var after_response = function(info) { - - display_element.html(''); // clear the display - - save_data(info.key, info.rt); - - if (trial.timing_post_trial > 0) { - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } - else { - block.next(); - } // call block.next() to advance the experiment after a delay. - - }; - - var mouse_listener = function(e) { - - var rt = (new Date()).getTime() - start_time; - - display_element.unbind('click', mouse_listener); - - after_response({key: 'mouse', rt: rt}); - - }; - - // check if key is 'mouse' - if (trial.cont_key == 'mouse') { - display_element.click(mouse_listener); - var start_time = (new Date()).getTime(); - } else { - jsPsych.pluginAPI.getKeyboardResponse(after_response, trial.cont_key); - } - - function save_data(key, rt) { - block.writeData($.extend({}, { - "trial_type": "text", - "trial_index": block.trial_idx, - "rt": rt, - "key_press": key - }, trial.data)); - } - }; - - return plugin; - })(); -})(jQuery); diff --git a/scripts/plugins/jspsych-video-button-response.js b/scripts/plugins/jspsych-video-button-response.js new file mode 100644 index 0000000..b2179b9 --- /dev/null +++ b/scripts/plugins/jspsych-video-button-response.js @@ -0,0 +1,335 @@ +/** + * jspsych-video-button-response + * Josh de Leeuw + * + * plugin for playing a video file and getting a button response + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["video-button-response"] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('video-button-response', 'stimulus', 'video'); + + plugin.info = { + name: 'video-button-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.VIDEO, + pretty_name: 'Video', + default: undefined, + description: 'The video file to play.' + }, + choices: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Choices', + default: undefined, + array: true, + description: 'The labels for the buttons.' + }, + button_html: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button HTML', + default: '', + array: true, + description: 'The html of the button. Can create own style.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the buttons.' + }, + width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Width', + default: '', + description: 'The width of the video in pixels.' + }, + height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Height', + default: '', + description: 'The height of the video display in pixels.' + }, + autoplay: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Autoplay', + default: true, + description: 'If true, the video will begin playing as soon as it has loaded.' + }, + controls: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Controls', + default: false, + description: 'If true, the subject will be able to pause the video or move the playback to any point in the video.' + }, + start: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Start', + default: null, + description: 'Time to start the clip.' + }, + stop: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Stop', + default: null, + description: 'Time to stop the clip.' + }, + rate: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Rate', + default: 1, + description: 'The playback rate of the video. 1 is normal, <1 is slower, >1 is faster.' + }, + trial_ends_after_video: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'End trial after video finishes', + default: false, + description: 'If true, the trial will end immediately after the video finishes playing.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show trial before it ends.' + }, + margin_vertical: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin vertical', + default: '0px', + description: 'The vertical margin of the button.' + }, + margin_horizontal: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Margin horizontal', + default: '8px', + description: 'The horizontal margin of the button.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, the trial will end when subject makes a response.' + }, + response_allowed_while_playing: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response allowed while playing', + default: true, + description: 'If true, then responses are allowed while the video is playing. '+ + 'If false, then the video must finish playing before a response is accepted.' + } + } + } + + plugin.trial = function(display_element, trial) { + + // setup stimulus + var video_html = '
' + video_html += '"; + video_html += "
"; + + //display buttons + var buttons = []; + if (Array.isArray(trial.button_html)) { + if (trial.button_html.length == trial.choices.length) { + buttons = trial.button_html; + } else { + console.error('Error in video-button-response plugin. The length of the button_html array does not equal the length of the choices array'); + } + } else { + for (var i = 0; i < trial.choices.length; i++) { + buttons.push(trial.button_html); + } + } + video_html += '
'; + for (var i = 0; i < trial.choices.length; i++) { + var str = buttons[i].replace(/%choice%/g, trial.choices[i]); + video_html += '
'+str+'
'; + } + video_html += '
'; + + // add prompt if there is one + if (trial.prompt !== null) { + video_html += trial.prompt; + } + + display_element.innerHTML = video_html; + + var start_time = performance.now(); + + var video_element = display_element.querySelector('#jspsych-video-button-response-stimulus'); + + if(video_preload_blob){ + video_element.src = video_preload_blob; + } + + video_element.onended = function(){ + if(trial.trial_ends_after_video){ + end_trial(); + } else if (!trial.response_allowed_while_playing) { + enable_buttons(); + } + } + + video_element.playbackRate = trial.rate; + + // if video start time is specified, hide the video and set the starting time + // before showing and playing, so that the video doesn't automatically show the first frame + if(trial.start !== null){ + video_element.pause(); + video_element.currentTime = trial.start; + video_element.onseeked = function() { + video_element.style.visibility = "visible"; + if (trial.autoplay) { + video_element.play(); + } + } + } + + if(trial.stop !== null){ + video_element.addEventListener('timeupdate', function(e){ + var currenttime = video_element.currentTime; + if(currenttime >= trial.stop){ + video_element.pause(); + } + }) + } + + if(trial.response_allowed_while_playing){ + enable_buttons(); + } else { + disable_buttons(); + } + + // store response + var response = { + rt: null, + button: null + }; + + // function to end trial when it is time + function end_trial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // stop the video file if it is playing + // remove any remaining end event handlers + display_element.querySelector('#jspsych-video-button-response-stimulus').pause(); + display_element.querySelector('#jspsych-video-button-response-stimulus').onended = function() {}; + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.button + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + } + + // function to handle responses by the subject + function after_response(choice) { + + // measure rt + var end_time = performance.now(); + var rt = end_time - start_time; + response.button = parseInt(choice); + response.rt = rt; + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + video_element.className += ' responded'; + + // disable all the buttons after a response + disable_buttons(); + + if (trial.response_ends_trial) { + end_trial(); + } + } + + function button_response(e){ + var choice = e.currentTarget.getAttribute('data-choice'); // don't use dataset for jsdom compatibility + after_response(choice); + } + + function disable_buttons() { + var btns = document.querySelectorAll('.jspsych-video-button-response-button'); + for (var i=0; i1 is faster.' + }, + trial_ends_after_video: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'End trial after video finishes', + default: false, + description: 'If true, the trial will end immediately after the video finishes playing.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show trial before it ends.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, the trial will end when subject makes a response.' + }, + response_allowed_while_playing: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response allowed while playing', + default: true, + description: 'If true, then responses are allowed while the video is playing. '+ + 'If false, then the video must finish playing before a response is accepted.' + } + } + } + + plugin.trial = function(display_element, trial) { + + // setup stimulus + var video_html = '
' + video_html += '"; + video_html += "
"; + + // add prompt if there is one + if (trial.prompt !== null) { + video_html += trial.prompt; + } + + display_element.innerHTML = video_html; + + var video_element = display_element.querySelector('#jspsych-video-keyboard-response-stimulus'); + + if(video_preload_blob){ + video_element.src = video_preload_blob; + } + + video_element.onended = function(){ + if(trial.trial_ends_after_video){ + end_trial(); + } + if ((trial.response_allowed_while_playing == false) & (!trial.trial_ends_after_video)) { + // start keyboard listener + var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false, + }); + } + } + + video_element.playbackRate = trial.rate; + + // if video start time is specified, hide the video and set the starting time + // before showing and playing, so that the video doesn't automatically show the first frame + if(trial.start !== null){ + video_element.pause(); + video_element.currentTime = trial.start; + video_element.onseeked = function() { + video_element.style.visibility = "visible"; + if (trial.autoplay) { + video_element.play(); + } + } + } + + if(trial.stop !== null){ + video_element.addEventListener('timeupdate', function(e){ + var currenttime = video_element.currentTime; + if(currenttime >= trial.stop){ + video_element.pause(); + } + }) + } + + // store response + var response = { + rt: null, + key: null + }; + + // function to end trial when it is time + function end_trial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // kill keyboard listeners + jsPsych.pluginAPI.cancelAllKeyboardResponses(); + + // stop the video file if it is playing + // remove end event listeners if they exist + display_element.querySelector('#jspsych-video-keyboard-response-stimulus').pause(); + display_element.querySelector('#jspsych-video-keyboard-response-stimulus').onended = function(){ }; + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.key + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + } + + // function to handle responses by the subject + var after_response = function(info) { + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector('#jspsych-video-keyboard-response-stimulus').className += ' responded'; + + // only record the first response + if (response.key == null) { + response = info; + } + + if (trial.response_ends_trial) { + end_trial(); + } + }; + + // start the response listener + if ((trial.choices != jsPsych.NO_KEYS) & (trial.response_allowed_while_playing)) { + var keyboardListener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: false, + allow_held_key: false, + }); + } + + // end trial if time limit is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-video-slider-response.js b/scripts/plugins/jspsych-video-slider-response.js new file mode 100644 index 0000000..6eab440 --- /dev/null +++ b/scripts/plugins/jspsych-video-slider-response.js @@ -0,0 +1,351 @@ +/** + * jspsych-video-slider-response + * Josh de Leeuw + * + * plugin for playing a video file and getting a slider response + * + * documentation: docs.jspsych.org + * + **/ + +jsPsych.plugins["video-slider-response"] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('video-slider-response', 'stimulus', 'video'); + + plugin.info = { + name: 'video-slider-response', + description: '', + parameters: { + stimulus: { + type: jsPsych.plugins.parameterType.VIDEO, + pretty_name: 'Video', + default: undefined, + description: 'The video file to play.' + }, + prompt: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Prompt', + default: null, + description: 'Any content here will be displayed below the stimulus.' + }, + width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Width', + default: '', + description: 'The width of the video in pixels.' + }, + height: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Height', + default: '', + description: 'The height of the video display in pixels.' + }, + autoplay: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Autoplay', + default: true, + description: 'If true, the video will begin playing as soon as it has loaded.' + }, + controls: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Controls', + default: false, + description: 'If true, the subject will be able to pause the video or move the playback to any point in the video.' + }, + start: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Start', + default: null, + description: 'Time to start the clip.' + }, + stop: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Stop', + default: null, + description: 'Time to stop the clip.' + }, + rate: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: 'Rate', + default: 1, + description: 'The playback rate of the video. 1 is normal, <1 is slower, >1 is faster.' + }, + min: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Min slider', + default: 0, + description: 'Sets the minimum value of the slider.' + }, + max: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Max slider', + default: 100, + description: 'Sets the maximum value of the slider', + }, + slider_start: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Slider starting value', + default: 50, + description: 'Sets the starting value of the slider', + }, + step: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Step', + default: 1, + description: 'Sets the step of the slider' + }, + labels: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name:'Labels', + default: [], + array: true, + description: 'Labels of the slider.', + }, + slider_width: { + type: jsPsych.plugins.parameterType.INT, + pretty_name:'Slider width', + default: null, + description: 'Width of the slider in pixels.' + }, + button_label: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: 'Button label', + default: 'Continue', + array: false, + description: 'Label of the button to advance.' + }, + require_movement: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Require movement', + default: false, + description: 'If true, the participant will have to move the slider before continuing.' + }, + trial_ends_after_video: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'End trial after video finishes', + default: false, + description: 'If true, the trial will end immediately after the video finishes playing.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'How long to show trial before it ends.' + }, + response_ends_trial: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response ends trial', + default: true, + description: 'If true, the trial will end when subject makes a response.' + }, + response_allowed_while_playing: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Response allowed while playing', + default: true, + description: 'If true, then responses are allowed while the video is playing. '+ + 'If false, then the video must finish playing before a response is accepted.' + } + } + } + + plugin.trial = function(display_element, trial) { + + // half of the thumb width value from jspsych.css, used to adjust the label positions + var half_thumb_width = 7.5; + + // setup stimulus + var video_html = '"; + + var html = '
'; + html += '
' + video_html + '
'; + html += '
'; + html += ''; + html += ''+trial.labels[j]+''; + html += '
' + } + html += '
'; + html += '
'; + html += '
'; + + // add prompt if there is one + if (trial.prompt !== null) { + html += '
'+trial.prompt+'
'; + } + + // add submit button + var next_disabled_attribute = ""; + if (trial.require_movement | !trial.response_allowed_while_playing) { + next_disabled_attribute = "disabled"; + } + html += ''; + + display_element.innerHTML = html; + + var video_element = display_element.querySelector('#jspsych-video-slider-response-stimulus-video'); + + if(video_preload_blob){ + video_element.src = video_preload_blob; + } + + video_element.onended = function(){ + if(trial.trial_ends_after_video){ + end_trial(); + } else if (!trial.response_allowed_while_playing) { + enable_slider(); + } + } + + video_element.playbackRate = trial.rate; + + // if video start time is specified, hide the video and set the starting time + // before showing and playing, so that the video doesn't automatically show the first frame + if(trial.start !== null){ + video_element.pause(); + video_element.currentTime = trial.start; + video_element.onseeked = function() { + video_element.style.visibility = "visible"; + if (trial.autoplay) { + video_element.play(); + } + } + } + + if(trial.stop !== null){ + video_element.addEventListener('timeupdate', function(e){ + var currenttime = video_element.currentTime; + if(currenttime >= trial.stop){ + video_element.pause(); + } + }) + } + + if(trial.require_movement){ + display_element.querySelector('#jspsych-video-slider-response-response').addEventListener('click', function(){ + display_element.querySelector('#jspsych-video-slider-response-next').disabled = false; + }); + } + + var startTime = performance.now(); + + // store response + var response = { + rt: null, + response: null + }; + + display_element.querySelector('#jspsych-video-slider-response-next').addEventListener('click', function() { + // measure response time + var endTime = performance.now(); + response.rt = endTime - startTime; + response.response = display_element.querySelector('#jspsych-video-slider-response-response').valueAsNumber; + + if(trial.response_ends_trial){ + end_trial(); + } else { + display_element.querySelector('#jspsych-video-slider-response-next').disabled = true; + } + + }); + + // function to end trial when it is time + function end_trial() { + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // stop the video file if it is playing + // remove any remaining end event handlers + display_element.querySelector('#jspsych-video-slider-response-stimulus-video').pause(); + display_element.querySelector('#jspsych-video-slider-response-stimulus-video').onended = function() {}; + + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + start: trial.start, + slider_start: trial.slider_start, + response: response.response + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + // function to enable slider after video ends + function enable_slider() { + document.querySelector('#jspsych-video-slider-response-response').disabled = false; + if (!trial.require_movement) { + document.querySelector('#jspsych-video-slider-response-next').disabled = false; + } + } + + // end trial if time limit is set + if (trial.trial_duration !== null) { + jsPsych.pluginAPI.setTimeout(function() { + end_trial(); + }, trial.trial_duration); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-virtual-chinrest.js b/scripts/plugins/jspsych-virtual-chinrest.js new file mode 100644 index 0000000..0875461 --- /dev/null +++ b/scripts/plugins/jspsych-virtual-chinrest.js @@ -0,0 +1,471 @@ +/* + * virtual chinrest plugin for jsPsych, based on Qisheng Li 11/2019. /// https://github.com/QishengLi/virtual_chinrest + + Modified by Gustavo Juantorena 08/2020 // https://github.com/GEJ1 + + Contributions from Peter J. Kohler: https://github.com/pjkohler + */ + +jsPsych.plugins["virtual-chinrest"] = (function () { + var plugin = {}; + + plugin.info = { + name: "virtual-chinrest", + parameters: { + resize_units: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: "Resize units", + default: "none", + description: + 'What units to resize to? ["none"/"cm"/"inch"/"deg"]. If "none", no resizing will be done to the jsPsych content after this trial.', + }, + pixels_per_unit: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Pixels per unit", + default: 100, + description: + "After the scaling factor is applied, this many pixels will equal one unit of measurement.", + }, + // mouse_adjustment: { + // type: jsPsych.plugins.parameterType.BOOL, + // pretty_name: "Adjust Using Mouse?", + // default: true, + // }, + adjustment_prompt: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: "Adjustment prompt", + default: ` +
+

Click and drag the lower right corner of the image until it is the same size as a credit card held up to the screen.

+

You can use any card that is the same size as a credit card, like a membership card or driver's license.

+

If you do not have access to a real card you can use a ruler to measure the image width to 3.37 inches or 85.6 mm.

+
`, + description: + "Any content here will be displayed above the card stimulus.", + }, + adjustment_button_prompt: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: "Adjustment button prompt", + default: "Click here when the image is the correct size", + description: + " Content of the button displayed below the card stimulus.", + }, + item_path: { + type: jsPsych.plugins.parameterType.STRING, + pretty_name: "Item path", + default: "img/card.png", + description: "Path to an image to be shown in the resizable item div." + }, + item_height_mm: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: "Item height (mm)", + default: 53.98, + description: "The height of the item to be measured, in mm.", + }, + item_width_mm: { + type: jsPsych.plugins.parameterType.FLOAT, + pretty_name: "Item width (mm)", + default: 85.6, + description: "The width of the item to be measured, in mm.", + }, + item_init_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Initial Size", + default: 250, + description: + "The initial size of the card, in pixels, along the largest dimension.", + }, + blindspot_reps: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: "Blindspot measurement repetitions", + default: 5, + description: + "How many times to measure the blindspot location? If 0, blindspot will not be detected, and viewing distance and degree data not computed.", + }, + blindspot_prompt: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: "Blindspot prompt", + default: ` +

Now we will quickly measure how far away you are sitting.

+
+
    +
  1. Put your left hand on the space bar.
  2. +
  3. Cover your right eye with your right hand.
  4. +
  5. Using your left eye, focus on the black square. Keep your focus on the black square.
  6. +
  7. The red ball will disappear as it moves from right to left. Press the space bar as soon as the ball disappears.
  8. +
+
+

Press the space bar when you are ready to begin.

+ `, + description: "HTML-formatted prompt to be shown on the screen during blindspot estimates." + }, + // blindspot_start_prompt: { + // type: jsPsych.plugins.parameterType.HTML_STRING, + // pretty_name: "Blindspot start prompt", + // default: "Start", + // description: "Content of the start button for the blindspot tasks.", + // }, + blindspot_measurements_prompt: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: "Blindspot measurements prompt", + default: "Remaining measurements: ", + description: "Text accompanying the remaining measures counter", + }, + viewing_distance_report: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: "Viewing distance report", + default: "

Based on your responses, you are sitting about from the screen.

Does that seem about right?

", + description: + 'If "none" is given, viewing distance will not be reported to the participant', + }, + redo_measurement_button_label: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: "Re-do measurement button label", + default: 'No, that is not close. Try again.', + description: "Label for the button that can be clicked on the viewing distance report screen to re-do the blindspot estimate(s)." + }, + blindspot_done_prompt: { + type: jsPsych.plugins.parameterType.HTML_STRING, + pretty_name: "Blindspot done prompt", + default: "Yes", + description: "Label for the button that can be clicked on the viewing distance report screen to accept the viewing distance estimate.", + }, + }, + }; + + plugin.trial = function (display_element, trial) { + /* check parameter compatibility */ + if (!(trial.blindspot_reps > 0) && (trial.resize_units == "deg" || trial.resize_units == "degrees")) { + console.error("Blindspot repetitions set to 0, so resizing to degrees of visual angle is not possible!"); + return; + } + + /* some additional parameter configuration */ + let trial_data = { + item_width_mm: trial.item_width_mm, + item_height_mm: trial.item_height_mm, //card dimension: 85.60 × 53.98 mm (3.370 × 2.125 in) + }; + + let blindspot_config_data = { + ball_pos: [], + slider_clck: false, + }; + + let aspect_ratio = trial.item_width_mm / trial.item_height_mm; + + const start_div_height = + aspect_ratio < 1 + ? trial.item_init_size + : Math.round(trial.item_init_size / aspect_ratio); + const start_div_width = + aspect_ratio < 1 + ? Math.round(trial.item_init_size * aspect_ratio) + : trial.item_init_size; + const adjust_size = Math.round(start_div_width * 0.1); + + /* create content for first screen, resizing card */ + let pagesize_content = ` +
+
+
+
+
+ ${trial.adjustment_prompt} + +
+ ` + + /* create content for second screen, blind spot */ + let blindspot_content = ` +
+ ${trial.blindspot_prompt} +
+ + ${trial.blindspot_measurements_prompt} +
${trial.blindspot_reps}
+
` + + /* create content for final report screen */ + let report_content = ` +
+
+ ${trial.viewing_distance_report} +
+ + +
+ ` + + display_element.innerHTML = `
` + + const start_time = performance.now(); + startResizePhase(); + + function startResizePhase() { + display_element.querySelector('#content').innerHTML = pagesize_content; + + // Event listeners for mouse-based resize + let dragging = false; + let origin_x, origin_y; + let cx, cy; + const scale_div = display_element.querySelector("#item"); + + function mouseupevent() { + dragging = false; + }; + document.addEventListener("mouseup", mouseupevent); + + function mousedownevent(e) { + e.preventDefault(); + dragging = true; + origin_x = e.pageX; + origin_y = e.pageY; + cx = parseInt(scale_div.style.width); + cy = parseInt(scale_div.style.height); + }; + display_element.querySelector("#jspsych-resize-handle").addEventListener("mousedown", mousedownevent); + + function resizeevent(e) { + if (dragging) { + let dx = e.pageX - origin_x; + let dy = e.pageY - origin_y; + + if (Math.abs(dx) >= Math.abs(dy)) { + scale_div.style.width = + Math.round(Math.max(20, cx + dx * 2)) + "px"; + scale_div.style.height = + Math.round(Math.max(20, cx + dx * 2) / aspect_ratio) + "px"; + } else { + scale_div.style.height = + Math.round(Math.max(20, cy + dy * 2)) + "px"; + scale_div.style.width = + Math.round(aspect_ratio * Math.max(20, cy + dy * 2)) + "px"; + } + } + } + display_element.addEventListener("mousemove", resizeevent); + + display_element.querySelector("#end_resize_phase").addEventListener("click", finishResizePhase); + + } + + function finishResizePhase() { + // add item width info to data + const item_width_px = getScaledItemWidth(); + trial_data["item_width_px"] = Math.round(item_width_px); + const px2mm = convertPixelsToMM(item_width_px); + trial_data["px2mm"] = accurateRound(px2mm, 2); + // check what to do next + if (trial.blindspot_reps > 0) { + startBlindSpotPhase(); + } else { + endTrial(); + } + } + + function startBlindSpotPhase() { + // reset the config data in case we are redoing the measurement + blindspot_config_data = { + ball_pos: [], + slider_clck: false, + }; + // add the content to the page + document.querySelector("#content").innerHTML = blindspot_content; + // draw the ball and fixation square + drawBall(); + // wait for a spacebar to begin the animations + jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: startBall, + valid_responses: [' '], + rt_method: 'performance', + allow_held_keys: false, + persist: false + }) + } + + function startBall() { + ball_position_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: recordPosition, + valid_responses: [' '], + rt_method: 'performance', + allow_held_keys: false, + persist: true + }); + animateBall(); + } + + function finishBlindSpotPhase() { + ball.stop(); + + jsPsych.pluginAPI.cancelAllKeyboardResponses(); + + if(trial.viewing_distance_report == 'none'){ + endTrial(); + } else { + showReport(); + } + } + + function showReport() { + // Display data + display_element.querySelector("#content").innerHTML = report_content; + display_element.querySelector('#distance-estimate').innerHTML = ` + ${Math.round(trial_data["view_dist_mm"] / 10)} cm (${Math.round(trial_data["view_dist_mm"]*0.0393701)} inches) + ` + + display_element.querySelector("#redo_blindspot").addEventListener('click', startBlindSpotPhase) + display_element.querySelector("#proceed").addEventListener('click', endTrial); + } + + function computeTransformation() { + trial_data.item_width_deg = + (2 * + Math.atan( + trial_data["item_width_mm"] / 2 / trial_data["view_dist_mm"] + ) * + 180) / + Math.PI; + trial_data.px2deg = + trial_data["item_width_px"] / trial_data.item_width_deg; // size of item in pixels divided by size of item in degrees of visual angle + + let px2unit_scr = 0; + switch (trial.resize_units) { + case "cm": + case "centimeters": + px2unit_scr = trial_data["px2mm"] * 10; // pixels per centimeter + break; + case "inch": + case "inches": + px2unit_scr = trial_data["px2mm"] * 25.4; // pixels per inch + break; + case "deg": + case "degrees": + px2unit_scr = trial_data["px2deg"]; // pixels per degree of visual angle + break; + } + if (px2unit_scr > 0) { + // scale the window + scale_factor = px2unit_scr / trial.pixels_per_unit; + document.getElementById("jspsych-content").style.transform = + "scale(" + scale_factor + ")"; + // pixels have been scaled, so pixels per degree, pixels per mm and pixels per item_width needs to be updated + trial_data.px2deg = trial_data.px2deg / scale_factor; + trial_data.px2mm = trial_data.px2mm / scale_factor; + trial_data.item_width_px = + trial_data.item_width_px / scale_factor; + trial_data.scale_factor = scale_factor; + } + + if (trial.blindspot_reps > 0) { + trial_data.win_width_deg = window.innerWidth / trial_data.px2deg; + trial_data.win_height_deg = + window.innerHeight / trial_data.px2deg; + } else { + // delete degree related properties + delete trial_data.px2deg; + delete trial_data.item_width_deg; + } + } + + function endTrial() { + + // finish trial + trial_data.rt = performance.now() - start_time; + + // remove lingering event listeners, just in case + jsPsych.pluginAPI.cancelAllKeyboardResponses(); + + // compute final data + computeTransformation(); + + // clear the display + display_element.innerHTML = ""; + + // finish the trial + jsPsych.finishTrial(trial_data); + + } + + function getScaledItemWidth() { + return document.querySelector('#item').getBoundingClientRect().width; + } + + function drawBall(pos = 180) { + // pos: define where the fixation square should be. + var mySVG = SVG("svgDiv"); + const rectX = trial_data["px2mm"] * pos; + const ballX = rectX * 0.6; // define where the ball is + var ball = mySVG.circle(30).move(ballX, 50).fill("#f00"); + window.ball = ball; + var square = mySVG.rect(30, 30).move(Math.min(rectX - 50, 950), 50); //square position + blindspot_config_data["square_pos"] = accurateRound(square.cx(), 2); + blindspot_config_data["rectX"] = rectX; + blindspot_config_data["ballX"] = ballX; + } + + function animateBall() { + ball + .animate(7000) + .during(function (pos) { + moveX = -pos * blindspot_config_data["ballX"]; + window.moveX = moveX; + moveY = 0; + ball.attr({ transform: "translate(" + moveX + "," + moveY + ")" }); //jqueryToVanilla: el.getAttribute(''); + }) + .loop(true, false) + .after(function () { + animateBall(); + }); + } + + function recordPosition() { + // angle: define horizontal blind spot entry point position in degrees. + const angle = 13.5; + + blindspot_config_data["ball_pos"].push(accurateRound(ball.cx() + moveX, 2)); + var sum = blindspot_config_data["ball_pos"].reduce((a, b) => a + b, 0); + var ballPosLen = blindspot_config_data["ball_pos"].length; + blindspot_config_data["avg_ball_pos"] = accurateRound(sum / ballPosLen, 2); + var ball_sqr_distance = + (blindspot_config_data["square_pos"] - blindspot_config_data["avg_ball_pos"]) / + trial_data["px2mm"]; + var viewDistance = ball_sqr_distance / Math.tan(Math.radians(angle)); + trial_data["view_dist_mm"] = accurateRound(viewDistance, 2); + + //counter and stop + var counter = Number(document.querySelector("#click").textContent); + counter = counter - 1; + document.querySelector("#click").textContent = Math.max(counter, 0); + if (counter <= 0) { + finishBlindSpotPhase(); + return; + } else { + ball.stop(); + animateBall(); + } + + } + + function convertPixelsToMM(item_width_px){ + const px2mm = item_width_px / trial_data["item_width_mm"]; + return px2mm; + } + + function accurateRound(value, decimals){ + return Number(Math.round(value+'e'+decimals)+'e-'+decimals); + } + + }; + + //helper function for radians + // Converts from degrees to radians. + Math.radians = function (degrees) { + return (degrees * Math.PI) / 180; + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-visual-search-circle.js b/scripts/plugins/jspsych-visual-search-circle.js index 60f98e9..85cf5d1 100644 --- a/scripts/plugins/jspsych-visual-search-circle.js +++ b/scripts/plugins/jspsych-visual-search-circle.js @@ -1,202 +1,259 @@ /** + * * jspsych-visual-search-circle * Josh de Leeuw - * + * * display a set of objects, with or without a target, equidistant from fixation * subject responds to whether or not the target is present - * + * * based on code written for psychtoolbox by Ben Motz - * - * todo: - * - * allow for use of display_element - * + * + * documentation: docs.jspsych.org + * **/ -(function($) { - jsPsych["visual-search-circle"] = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - var trials = new Array(params.target_present.length); - - for (var i = 0; i < trials.length; i++) { - trials[i] = {}; - trials[i].type = "visual-search-circle"; - trials[i].target_present = params.target_present[i]; - trials[i].set_size = params.set_size[i]; - trials[i].target = params.target; - trials[i].foil = params.foil; - trials[i].fixation_image = params.fixation_image; - trials[i].target_size = params.target_size || [50, 50]; - trials[i].fixation_size = params.fixation_size || [16, 16]; - trials[i].circle_diameter = params.circle_diameter || 250; - trials[i].target_present_key = params.target_present_key || 74; - trials[i].target_absent_key = params.target_absent_key || 70; - trials[i].timing_max_search = (typeof params.timing_max_search === 'undefined') ? -1 : params.timing_max_search; - trials[i].timing_fixation = (typeof params.timing_fixation === 'undefined') ? 1000 : params.timing_fixation; - trials[i].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial; - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data[i]; - } - - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - // screen information - var screenw = $(window).width(); - var screenh = $(window).height(); - var centerx = screenw / 2; - var centery = screenh / 2; - - // circle params - var diam = trial.circle_diameter; // pixels - var radi = diam / 2; - var paper_size = diam + trial.target_size[0]; - - // stimuli width, height - var stimh = trial.target_size[0]; - var stimw = trial.target_size[1]; - var hstimh = stimh / 2; - var hstimw = stimw / 2; - - // fixation location - var fix_loc = [Math.floor(paper_size / 2 - trial.fixation_size[0] / 2), Math.floor(paper_size / 2 - trial.fixation_size[1] / 2)]; - - // possible stimulus locations on the circle - var display_locs = []; - var possible_display_locs = trial.set_size; - var random_offset = Math.floor(Math.random()*360); - for(var i = 0; i < possible_display_locs; i++){ - display_locs.push([ - Math.floor(paper_size / 2 + (cosd(random_offset + (i * (360/possible_display_locs))) * radi) - hstimw), - Math.floor(paper_size / 2 - (sind(random_offset + (i * (360/possible_display_locs))) * radi) - hstimh) - ]); - } - - // get target to draw on - var paper = Raphael(centerx - paper_size / 2, centery - paper_size / 2, paper_size, paper_size); - - show_fixation(); - - function show_fixation() { - // show fixation - var fixation = paper.image(trial.fixation_image, fix_loc[0], fix_loc[1], trial.fixation_size[0], trial.fixation_size[1]); - - // wait - setTimeout(function() { - // after wait is over - show_search_array(); - }, trial.timing_fixation); - } - - function show_search_array() { - - var search_array_images = []; - - for (var i = 0; i < display_locs.length; i++) { - - var which_image = (i == 0 && trial.target_present) ? trial.target : trial.foil; - - var img = paper.image(which_image, display_locs[i][0], display_locs[i][1], trial.target_size[0], trial.target_size[1]); - - search_array_images.push(img); - - } - - var trial_over = false; - - var after_response = function(info){ - - trial_over = true; - - var correct = 0; - - if (info.key == trial.target_present_key && trial.target_present || - info.key == trial.target_absent_key && !trial.target_present) { - correct = 1; - } - - clear_display(); - - end_trial(info.rt, correct, info.key); - - } - - var valid_keys = [trial.target_present_key, trial.target_absent_key]; - - key_listener = jsPsych.pluginAPI.getKeyboardResponse(after_response, valid_keys, 'date',false); - - if (trial.timing_max_search > 0) { - setTimeout(function() { - - if (!trial_over) { - - jsPsych.pluginAPI.cancelKeyboardResponse(key_listener); - - trial_over = true; - - var rt = -1; - var correct = 0; - var key_press = -1; - - clear_display(); - - end_trial(rt, correct, key_press); - } - }, trial.timing_max_search); - } - - function clear_display() { - paper.remove(); - } - } - - - function end_trial(rt, correct, key_press) { - - // data saving - var trial_data = { - trial_type: trial.type, - trial_index: block.trial_idx, - correct: correct, - rt: rt, - key_press: key_press, - locations: JSON.stringify(display_locs), - target_present: trial.target_present, - set_size: trial.set_size - }; - - // this line merges together the trial_data object and the generic - // data object (trial.data), and then stores them. - block.writeData($.extend({}, trial_data, trial.data)); - - // go to next trial - if(trial.timing_post_trial > 0){ - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } else { - block.next(); - } - } - }; - - // helper function for determining stimulus locations - - function cosd(num) { - return Math.cos(num / 180 * Math.PI); +jsPsych.plugins["visual-search-circle"] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('visual-search-circle', 'target', 'image'); + jsPsych.pluginAPI.registerPreload('visual-search-circle', 'foil', 'image'); + jsPsych.pluginAPI.registerPreload('visual-search-circle', 'fixation_image', 'image'); + + plugin.info = { + name: 'visual-search-circle', + description: '', + parameters: { + target: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Target', + default: undefined, + description: 'The image to be displayed.' + }, + foil: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Foil', + default: undefined, + description: 'Path to image file that is the foil/distractor.' + }, + fixation_image: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Fixation image', + default: undefined, + description: 'Path to image file that is a fixation target.' + }, + set_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Set size', + default: undefined, + description: 'How many items should be displayed?' + }, + target_present: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Target present', + default: true, + description: 'Is the target present?' + }, + target_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Target size', + array: true, + default: [50, 50], + description: 'Two element array indicating the height and width of the search array element images.' + }, + fixation_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Fixation size', + array: true, + default: [16, 16], + description: 'Two element array indicating the height and width of the fixation image.' + }, + circle_diameter: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Circle diameter', + default: 250, + description: 'The diameter of the search array circle in pixels.' + }, + target_present_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Target present key', + default: 'j', + description: 'The key to press if the target is present in the search array.' + }, + target_absent_key: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Target absent key', + default: 'f', + description: 'The key to press if the target is not present in the search array.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: null, + description: 'The maximum duration to wait for a response.' + }, + fixation_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Fixation duration', + default: 1000, + description: 'How long to show the fixation image for before the search array (in milliseconds).' + } + } + } + + plugin.trial = function(display_element, trial) { + + // circle params + var diam = trial.circle_diameter; // pixels + var radi = diam / 2; + var paper_size = diam + trial.target_size[0]; + + // stimuli width, height + var stimh = trial.target_size[0]; + var stimw = trial.target_size[1]; + var hstimh = stimh / 2; + var hstimw = stimw / 2; + + // fixation location + var fix_loc = [Math.floor(paper_size / 2 - trial.fixation_size[0] / 2), Math.floor(paper_size / 2 - trial.fixation_size[1] / 2)]; + + // possible stimulus locations on the circle + var display_locs = []; + var possible_display_locs = trial.set_size; + var random_offset = Math.floor(Math.random() * 360); + for (var i = 0; i < possible_display_locs; i++) { + display_locs.push([ + Math.floor(paper_size / 2 + (cosd(random_offset + (i * (360 / possible_display_locs))) * radi) - hstimw), + Math.floor(paper_size / 2 - (sind(random_offset + (i * (360 / possible_display_locs))) * radi) - hstimh) + ]); + } + + // get target to draw on + display_element.innerHTML += '
'; + var paper = display_element.querySelector("#jspsych-visual-search-circle-container"); + + // check distractors - array? + if(!Array.isArray(trial.foil)){ + fa = []; + for(var i=0; i"; + + // wait + jsPsych.pluginAPI.setTimeout(function() { + // after wait is over + show_search_array(); + }, trial.fixation_duration); + } + + function show_search_array() { + + var search_array_images = []; + + var to_present = []; + if(trial.target_present){ + to_present.push(trial.target); + } + to_present = to_present.concat(trial.foil); + + for (var i = 0; i < display_locs.length; i++) { + + paper.innerHTML += ""; + + } + + var trial_over = false; + + var after_response = function(info) { + + trial_over = true; + + var correct = false; + + if ((jsPsych.pluginAPI.compareKeys(info.key, trial.target_present_key)) && trial.target_present || + (jsPsych.pluginAPI.compareKeys(info.key, trial.target_absent_key)) && !trial.target_present) { + correct = true; } - function sind(num) { - return Math.sin(num / 180 * Math.PI); - } + clear_display(); + + end_trial(info.rt, correct, info.key); + + } + + var valid_keys = [trial.target_present_key, trial.target_absent_key]; + + key_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: valid_keys, + rt_method: 'performance', + persist: false, + allow_held_key: false + }); + + if (trial.trial_duration !== null) { + + jsPsych.pluginAPI.setTimeout(function() { + + if (!trial_over) { + + jsPsych.pluginAPI.cancelKeyboardResponse(key_listener); + + trial_over = true; + + var rt = null; + var correct = 0; + var key_press = null; + + clear_display(); + + end_trial(rt, correct, key_press); + } + }, trial.trial_duration); + + } + + function clear_display() { + display_element.innerHTML = ''; + } + } + + + function end_trial(rt, correct, key_press) { + + // data saving + var trial_data = { + correct: correct, + rt: rt, + response: key_press, + locations: display_locs, + target_present: trial.target_present, + set_size: trial.set_size + }; + + // go to next trial + jsPsych.finishTrial(trial_data); + } + }; + + // helper function for determining stimulus locations + + function cosd(num) { + return Math.cos(num / 180 * Math.PI); + } + + function sind(num) { + return Math.sin(num / 180 * Math.PI); + } - return plugin; - })(); -})(jQuery); + return plugin; +})(); diff --git a/scripts/plugins/jspsych-vsl-animate-occlusion.js b/scripts/plugins/jspsych-vsl-animate-occlusion.js index a4d2bb6..e6b1794 100644 --- a/scripts/plugins/jspsych-vsl-animate-occlusion.js +++ b/scripts/plugins/jspsych-vsl-animate-occlusion.js @@ -1,170 +1,196 @@ /** * jsPsych plugin for showing animations that mimic the experiment described in - * - * Fiser, J., & Aslin, R. N. (2002). Statistical learning of higher-order - * temporal structure from visual shape sequences. Journal of Experimental + * + * Fiser, J., & Aslin, R. N. (2002). Statistical learning of higher-order + * temporal structure from visual shape sequences. Journal of Experimental * Psychology: Learning, Memory, and Cognition, 28(3), 458. - * + * * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-vsl-animate-occlusion - * + * + * documentation: docs.jspsych.org + * */ -(function($) { - jsPsych['vsl-animate-occlusion'] = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - var trials = new Array(1); - - trials[0] = {}; - trials[0].type = "vsl-animate-occlusion"; - trials[0].stims = params.stimuli; - trials[0].timing_cycle = params.timing_cycle || 1000; - trials[0].canvas_size = params.canvas_size || [400, 400]; - trials[0].image_size = params.image_size || [100, 100]; - trials[0].initial_direction = params.initial_direction || "left"; - trials[0].occlude_center = (typeof params.occlude_center === 'undefined') ? true : params.occlude_center; - trials[0].choices = params.choices || []; // spacebar - // timing - trials[0].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial; - trials[0].timing_pre_movement = (typeof params.timing_pre_movement === 'undefined') ? 500 : params.timing_pre_movement; - //trials[0].prompt = (typeof params.prompt === 'undefined') ? "" : params.prompt; - trials[0].data = (typeof params.data === 'undefined') ? {} : params.data; - - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - // variable to keep track of timing info and responses - var start_time = 0; - var responses = []; - - var directions = [ - [{ - params: { - x: trial.canvas_size[0] - trial.image_size[0] - }, - ms: trial.timing_cycle / 2 - }, { - params: { - x: trial.canvas_size[0] / 2 - trial.image_size[0] / 2 - }, - ms: trial.timing_cycle / 2 - }], - [{ - params: { - x: 0 - }, - ms: trial.timing_cycle / 2 - }, { - params: { - x: trial.canvas_size[0] / 2 - trial.image_size[0] / 2 - }, - ms: trial.timing_cycle / 2 - }] - ]; - - var which_image = 0; - var next_direction = (trial.initial_direction == "right") ? 0 : 1; - - function next_step() { - if (trial.stims.length == which_image) { - endTrial(); - } - else { - - var d = directions[next_direction]; - next_direction === 0 ? next_direction = 1 : next_direction = 0; - var i = trial.stims[which_image]; - which_image++; - - eve.once("raphael.attr.src."+c.id, function(){ - c.animate(d[0].params, d[0].ms, function() { - c.animate(d[1].params, d[1].ms, function() { - next_step(); - }); - }); - }); - c.attr({ - src: i - }); - - // start timer for this trial - start_time = (new Date()).getTime(); - } - } - - display_element.append($("
", { - css: { - width: trial.canvas_size[0] + "px", - height: trial.canvas_size[1] + "px" - } - })); - - var paper = Raphael("jspsych-vsl-animate-occlusion-raphaelCanvas", trial.canvas_size[0], trial.canvas_size[1]); - - var c = paper.image(trial.stims[which_image], trial.canvas_size[0] / 2 - trial.image_size[0] / 2, trial.canvas_size[1] / 2 - trial.image_size[1] / 2, trial.image_size[0], trial.image_size[1]); - - if (trial.occlude_center) { - paper.rect((trial.canvas_size[0] / 2) - (trial.image_size[0] / 2), 0, trial.image_size[0], trial.canvas_size[1]).attr({ - fill: "#000" - }); - } - - // add key listener - var after_response = function(info){ - responses.push({ - key: info.key, - stimulus: which_image - 1, - rt: info.rt - }); - } - - key_listener = jsPsych.pluginAPI.getKeyboardResponse(after_response, trial.choices, 'date', true); - - if (trial.timing_pre_movement > 0) { - setTimeout(function() { - next_step(); - }, trial.timing_pre_movement); - } - else { - next_step(); - } - - function endTrial() { - - display_element.html(''); - - jsPsych.pluginAPI.cancelKeyboardResponse(key_listener); - - block.writeData($.extend({}, { - "trial_type": "vsl-animate-occlusion", - "trial_index": block.trial_idx, - "stimuli": JSON.stringify(trial.stims), - "responses": JSON.stringify(responses) - }, trial.data)); - - if (trial.timing_post_trial > 0) { - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } - else { - block.next(); - } - } - }; - - return plugin; - })(); -})(jQuery); +jsPsych.plugins['vsl-animate-occlusion'] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('vsl-animate-occlusion', 'stimuli', 'image'); + + plugin.info = { + name: 'vsl-animate-occlusion', + description: '', + parameters: { + stimuli: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimuli', + default: undefined, + array: true, + description: 'A stimulus is a path to an image file.' + }, + choices: { + type: jsPsych.plugins.parameterType.KEY, + pretty_name: 'Choices', + array: true, + default: jsPsych.ALL_KEYS, + description: 'This array contains the keys that the subject is allowed to press in order to respond to the stimulus. ' + }, + canvas_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Canvas size', + array: true, + default: [400,400], + description: 'Array specifying the width and height of the area that the animation will display in.' + }, + image_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Image size', + array: true, + default: [100,100], + description: 'Array specifying the width and height of the images to show.' + }, + initial_direction: { + type: jsPsych.plugins.parameterType.SELECT, + pretty_name: 'Initial direction', + choices: ['left','right'], + default: 'left', + description: 'Which direction the stimulus should move first.' + }, + occlude_center: { + type: jsPsych.plugins.parameterType.BOOL, + pretty_name: 'Occlude center', + default: true, + description: 'If true, display a rectangle in the center of the screen that is just wide enough to occlude the image completely as it passes behind.' + }, + cycle_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Cycle duration', + default: 1000, + description: 'How long it takes for a stimulus in the sequence to make a complete cycle.' + }, + pre_movement_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Pre movement duration', + default: 500, + description: 'How long to wait before the stimuli starts moving from behind the center rectangle.' + } + } + } + + plugin.trial = function(display_element, trial) { + + // variable to keep track of timing info and responses + var start_time = 0; + var responses = []; + + var directions = [ + [{ + params: { + x: trial.canvas_size[0] - trial.image_size[0] + }, + ms: trial.cycle_duration / 2 + }, { + params: { + x: trial.canvas_size[0] / 2 - trial.image_size[0] / 2 + }, + ms: trial.cycle_duration / 2 + }], + [{ + params: { + x: 0 + }, + ms: trial.cycle_duration / 2 + }, { + params: { + x: trial.canvas_size[0] / 2 - trial.image_size[0] / 2 + }, + ms: trial.cycle_duration / 2 + }] + ]; + + var which_image = 0; + var next_direction = (trial.initial_direction == "right") ? 0 : 1; + + function next_step() { + if (trial.stimuli.length == which_image) { + endTrial(); + } else { + + var d = directions[next_direction]; + next_direction === 0 ? next_direction = 1 : next_direction = 0; + var i = trial.stimuli[which_image]; + which_image++; + + c.animate(d[0].params, d[0].ms, mina.linear, function() { + c.animate(d[1].params, d[1].ms, mina.linear, function() { + next_step(); + }); + }); + + c.attr({ + href: i + }); + + // start timer for this trial + start_time = performance.now(); + } + } + + display_element.innerHTML = ""; + + var paper = Snap("#jspsych-vsl-animate-occlusion-canvas"); + + var c = paper.image(trial.stimuli[which_image], trial.canvas_size[0] / 2 - trial.image_size[0] / 2, trial.canvas_size[1] / 2 - trial.image_size[1] / 2, trial.image_size[0], trial.image_size[1]).attr({ + "id": 'jspsych-vsl-animate-occlusion-moving-image' + }); + + display_element.querySelector('#jspsych-vsl-animate-occlusion-moving-image').removeAttribute('preserveAspectRatio'); + + if (trial.occlude_center) { + paper.rect((trial.canvas_size[0] / 2) - (trial.image_size[0] / 2), 0, trial.image_size[0], trial.canvas_size[1]).attr({ + fill: "#000" + }); + } + + // add key listener + var after_response = function(info) { + responses.push({ + key: info.key, + stimulus: which_image - 1, + rt: info.rt + }); + } + + key_listener = jsPsych.pluginAPI.getKeyboardResponse({ + callback_function: after_response, + valid_responses: trial.choices, + rt_method: 'performance', + persist: true, + allow_held_key: false + }); + + if (trial.pre_movement_duration > 0) { + jsPsych.pluginAPI.setTimeout(function() { + next_step(); + }, trial.pre_movement_duration); + } else { + next_step(); + } + + function endTrial() { + + display_element.innerHTML = ''; + + jsPsych.pluginAPI.cancelKeyboardResponse(key_listener); + + var trial_data = { + stimuli: trial.stimuli, + response: responses + }; + + jsPsych.finishTrial(trial_data); + } + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-vsl-grid-scene.js b/scripts/plugins/jspsych-vsl-grid-scene.js index 9867f8a..6e6422b 100644 --- a/scripts/plugins/jspsych-vsl-grid-scene.js +++ b/scripts/plugins/jspsych-vsl-grid-scene.js @@ -1,141 +1,103 @@ /** * jsPsych plugin for showing scenes that mimic the experiments described in - * - * Fiser, J., & Aslin, R. N. (2001). Unsupervised statistical learning of - * higher-order spatial structures from visual scenes. Psychological science, + * + * Fiser, J., & Aslin, R. N. (2001). Unsupervised statistical learning of + * higher-order spatial structures from visual scenes. Psychological science, * 12(6), 499-504. - * + * * Josh de Leeuw - * - * documentation: https://github.com/jodeleeuw/jsPsych/wiki/jspsych-vsl-grid-scene - * + * + * documentation: docs.jspsych.org + * */ -(function($) { - jsPsych['vsl-grid-scene'] = (function() { - - var plugin = {}; - - plugin.create = function(params) { - - params = jsPsych.pluginAPI.enforceArray(params, ['data']) - var trials = new Array(params.stimuli.length); - for (var i = 0; i < trials.length; i++) { - trials[i] = {}; - trials[i].type = "vsl-grid-scene"; - trials[i].stimuli = params.stimuli[i]; - trials[i].image_size = params.image_size || [100, 100]; - trials[i].timing_post_trial = (typeof params.timing_post_trial === 'undefined') ? 1000 : params.timing_post_trial; - trials[i].timing_duration = (typeof params.timing_duration === 'undefined') ? 2000 : params.timing_duration; - //trials[i].prompt = (typeof params.prompt === 'undefined') ? "" : params.prompt; - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data; - } - return trials; - }; - - plugin.trial = function(display_element, block, trial, part) { - - // if any trial variables are functions - // this evaluates the function and replaces - // it with the output of the function - trial = jsPsych.pluginAPI.normalizeTrialVariables(trial); - - display_element.html(plugin.generate_stimulus(trial.stimuli, trial.image_size)); - - setTimeout(function() { - endTrial(); - }, trial.timing_duration); - - function endTrial() { - - display_element.html(''); - - block.writeData($.extend({}, { - "trial_type": "vsl-grid-scene", - "trial_index": block.trial_idx, - "stimuli": JSON.stringify(trial.stimuli) - }, trial.data)); - - if (trial.timing_post_trial > 0) { - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } - else { - block.next(); - } - } - }; - - plugin.generate_stimulus = function(pattern, image_size) { - var nrows = pattern.length; - var ncols = pattern[0].length; - - // create blank element to hold code that we generate - $('body').append($('
', { - id: 'jspsych-vsl-grid-scene-dummy', - css: { - display: 'none' - } - })); - - // create table - $('#jspsych-vsl-grid-scene-dummy').append($('', { - id: 'jspsych-vsl-grid-scene-table', - css: { - 'border-collapse': 'collapse', - 'margin-left': 'auto', - 'margin-right': 'auto' - } - })); - - for (var row = 0; row < nrows; row++) { - $("#jspsych-vsl-grid-scene-table").append($('', { - id: 'jspsych-vsl-grid-scene-table-row-' + row, - css: { - height: image_size[1] + "px" - } - })); - for (var col = 0; col < ncols; col++) { - $("#jspsych-vsl-grid-scene-table-row-" + row).append($('
', { - id: 'jspsych-vsl-grid-scene-table-' + row + '-' + col, - css: { - padding: image_size[1] / 10 + "px " + image_size[0] / 10 + "px", - border: '1px solid #555' - } - })); - $('#jspsych-vsl-grid-scene-table-' + row + '-' + col).append($('
', { - id: 'jspsych-vsl-grid-scene-table-cell-' + row + '-' + col, - css: { - width: image_size[0] + "px", - height: image_size[1] + "px" - } - })); - } - } - - - for (var row = 0; row < nrows; row++) { - for (var col = 0; col < ncols; col++) { - if (pattern[row][col] !== 0) { - $('#jspsych-vsl-grid-scene-table-cell-' + row + '-' + col).append($('', { - src: pattern[row][col], - css: { - width: image_size[0] + "px", - height: image_size[1] + "px", - } - })); - } - } - } - - var html_out = $('#jspsych-vsl-grid-scene-dummy').html(); - $('#jspsych-vsl-grid-scene-dummy').remove(); - - return html_out; - - }; - - return plugin; - })(); -})(jQuery); +jsPsych.plugins['vsl-grid-scene'] = (function() { + + var plugin = {}; + + jsPsych.pluginAPI.registerPreload('vsl-grid-scene', 'stimuli', 'image'); + + plugin.info = { + name: 'vsl-grid-scene', + description: '', + parameters: { + stimuli: { + type: jsPsych.plugins.parameterType.IMAGE, + pretty_name: 'Stimuli', + array: true, + default: undefined, + description: 'An array that defines a grid.' + }, + image_size: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Image size', + array: true, + default: [100,100], + description: 'Array specifying the width and height of the images to show.' + }, + trial_duration: { + type: jsPsych.plugins.parameterType.INT, + pretty_name: 'Trial duration', + default: 2000, + description: 'How long to show the stimulus for in milliseconds.' + } + } + } + + plugin.trial = function(display_element, trial) { + + display_element.innerHTML = plugin.generate_stimulus(trial.stimuli, trial.image_size); + + jsPsych.pluginAPI.setTimeout(function() { + endTrial(); + }, trial.trial_duration); + + function endTrial() { + + display_element.innerHTML = ''; + + var trial_data = { + stimulus: trial.stimuli + }; + + jsPsych.finishTrial(trial_data); + } + }; + + plugin.generate_stimulus = function(pattern, image_size) { + var nrows = pattern.length; + var ncols = pattern[0].length; + + // create blank element to hold code that we generate + var html = '
'; + + // create table + html += ''; + + for (var row = 0; row < nrows; row++) { + html += ''; + + for (var col = 0; col < ncols; col++) { + html += ''; + } + html += ''; + } + + html += '
'+ + '
'; + if (pattern[row][col] !== 0) { + html += ''; + } + html += '
'; + html += '
'; + html += '
'; + + return html; + + }; + + return plugin; +})(); diff --git a/scripts/plugins/jspsych-webgazer-calibrate.js b/scripts/plugins/jspsych-webgazer-calibrate.js new file mode 100644 index 0000000..54afe7e --- /dev/null +++ b/scripts/plugins/jspsych-webgazer-calibrate.js @@ -0,0 +1,166 @@ +/** + * jspsych-webgazer-calibrate + * Josh de Leeuw + **/ + +jsPsych.plugins["webgazer-calibrate"] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'webgazer-calibrate', + description: '', + parameters: { + calibration_points: { + type: jsPsych.plugins.parameterType.INT, + default: [[10,10], [10,50], [10,90], [50,10], [50,50], [50,90], [90,10], [90,50], [90,90]] + }, + calibration_mode: { + type: jsPsych.plugins.parameterType.STRING, + default: 'click', // options: 'click', 'view' + }, + repetitions_per_point: { + type: jsPsych.plugins.parameterType.INT, + default: 1 + }, + randomize_calibration_order: { + type: jsPsych.plugins.parameterType.BOOL, + default: false + }, + time_to_saccade: { + type: jsPsych.plugins.parameterType.INT, + default: 1000 + }, + time_per_point: { + type: jsPsych.plugins.parameterType.STRING, + default: 1000 + } + } + } + + // provide options for calibration routines? + // dot clicks? + // track a dot with mouse? + + // then a validation phase of staring at the dot in different locations? + + plugin.trial = function(display_element, trial) { + + var html = ` +
+
` + + display_element.innerHTML = html; + + jsPsych.extensions['webgazer'].resume(); + + var wg_container = display_element.querySelector('#webgazer-calibrate-container'); + + var reps_completed = 0; + var points_completed = -1; + var cal_points = null; + + calibrate(); + + + function calibrate(){ + jsPsych.extensions['webgazer'].resume(); + if(trial.calibration_mode == 'click'){ + jsPsych.extensions['webgazer'].startMouseCalibration(); + } + next_calibration_round(); + } + + function next_calibration_round(){ + if(trial.randomize_calibration_order){ + cal_points = jsPsych.randomization.shuffle(trial.calibration_points); + } else { + cal_points = trial.calibration_points; + } + points_completed = -1; + next_calibration_point(); + } + + function next_calibration_point(){ + points_completed++; + if(points_completed == cal_points.length){ + reps_completed++; + if(reps_completed == trial.repetitions_per_point){ + calibration_done(); + } else { + next_calibration_round(); + } + } else { + var pt = cal_points[points_completed]; + calibration_display_gaze_only(pt); + } + } + + function calibration_display_gaze_only(pt){ + var pt_html = '
' + wg_container.innerHTML = pt_html; + + var pt_dom = wg_container.querySelector('#calibration-point'); + + if(trial.calibration_mode == 'click'){ + pt_dom.style.cursor = 'pointer'; + pt_dom.addEventListener('click', function(){ + next_calibration_point(); + }) + } + + if(trial.calibration_mode == 'view'){ + var br = pt_dom.getBoundingClientRect(); + var x = br.left + br.width / 2; + var y = br.top + br.height / 2; + + var pt_start_cal = performance.now() + trial.time_to_saccade; + var pt_finish = performance.now() + trial.time_to_saccade + trial.time_per_point; + + requestAnimationFrame(function watch_dot(){ + + if(performance.now() > pt_start_cal){ + jsPsych.extensions['webgazer'].calibratePoint(x,y,'click'); + } + if(performance.now() < pt_finish){ + requestAnimationFrame(watch_dot); + } else { + next_calibration_point(); + } + }) + } + } + + function calibration_done(){ + if(trial.calibration_mode == 'click'){ + jsPsych.extensions['webgazer'].stopMouseCalibration(); + } + wg_container.innerHTML = ""; + end_trial(); + } + + // function to end trial when it is time + function end_trial() { + jsPsych.extensions['webgazer'].pause(); + jsPsych.extensions['webgazer'].hidePredictions(); + jsPsych.extensions['webgazer'].hideVideo(); + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // gather the data to store for the trial + var trial_data = { + + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + }; + + return plugin; + })(); \ No newline at end of file diff --git a/scripts/plugins/jspsych-webgazer-init-camera.js b/scripts/plugins/jspsych-webgazer-init-camera.js new file mode 100644 index 0000000..952a906 --- /dev/null +++ b/scripts/plugins/jspsych-webgazer-init-camera.js @@ -0,0 +1,95 @@ +/** + * jspsych-webgazer-init-camera + * Josh de Leeuw + **/ + +jsPsych.plugins["webgazer-init-camera"] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'webgazer-init-camera', + description: '', + parameters: { + instructions: { + type: jsPsych.plugins.parameterType.HTML_STRING, + default: ` +

Position your head so that the webcam has a good view of your eyes.

+

Use the video in the upper-left corner as a guide. Center your face in the box and look directly towards the camera.

+

It is important that you try and keep your head reasonably still throughout the experiment, so please take a moment to adjust your setup as needed.

+

When your face is centered in the box and the box turns green, you can click to continue.

` + }, + button_text: { + type: jsPsych.plugins.parameterType.STRING, + default: 'Continue' + } + } + } + + plugin.trial = function(display_element, trial) { + + var html = ` +
+
` + + display_element.innerHTML = html; + + jsPsych.extensions['webgazer'].showVideo(); + jsPsych.extensions['webgazer'].resume(); + + var wg_container = display_element.querySelector('#webgazer-init-container'); + + + wg_container.innerHTML = ` +
+ ${trial.instructions} + +
` + + var observer = new MutationObserver(face_detect_event_observer); + observer.observe(document, { + attributes: true, + attributeFilter: ['style'], + subtree: true + }); + + document.querySelector('#jspsych-wg-cont').addEventListener('click', function(){ + observer.disconnect(); + end_trial(); + }); + + function face_detect_event_observer(mutationsList, observer){ + if(mutationsList[0].target == document.querySelector('#webgazerFaceFeedbackBox')){ + if(mutationsList[0].type == 'attributes' && mutationsList[0].target.style.borderColor == "green"){ + document.querySelector('#jspsych-wg-cont').disabled = false; + } + if(mutationsList[0].type == 'attributes' && mutationsList[0].target.style.borderColor == "red"){ + document.querySelector('#jspsych-wg-cont').disabled = true; + } + } + } + + // function to end trial when it is time + function end_trial() { + jsPsych.extensions['webgazer'].pause(); + jsPsych.extensions['webgazer'].hideVideo(); + + // kill any remaining setTimeout handlers + jsPsych.pluginAPI.clearAllTimeouts(); + + // gather the data to store for the trial + var trial_data = { + + }; + + // clear the display + display_element.innerHTML = ''; + + // move on to the next trial + jsPsych.finishTrial(trial_data); + }; + + }; + + return plugin; + })(); \ No newline at end of file diff --git a/scripts/plugins/jspsych-webgazer-validate.js b/scripts/plugins/jspsych-webgazer-validate.js new file mode 100644 index 0000000..87f191a --- /dev/null +++ b/scripts/plugins/jspsych-webgazer-validate.js @@ -0,0 +1,304 @@ +/** + * jspsych-webgazer-validate + * Josh de Leeuw + **/ + +jsPsych.plugins["webgazer-validate"] = (function() { + + var plugin = {}; + + plugin.info = { + name: 'webgazer-validate', + description: '', + parameters: { + validation_points: { + type: jsPsych.plugins.parameterType.INT, + default: [[10,10], [10,50], [10,90], [50,10], [50,50], [50,90], [90,10], [90,50], [90,90]] + }, + validation_point_coordinates: { + type: jsPsych.plugins.parameterType.STRING, + default: 'percent' // options: 'percent', 'center-offset-pixels' + }, + roi_radius: { + type: jsPsych.plugins.parameterType.INT, + default: 200 + }, + randomize_validation_order: { + type: jsPsych.plugins.parameterType.BOOL, + default: false + }, + time_to_saccade: { + type: jsPsych.plugins.parameterType.INT, + default: 1000 + }, + validation_duration: { + type: jsPsych.plugins.parameterType.INT, + default: 2000 + }, + point_size:{ + type: jsPsych.plugins.parameterType.INT, + default: 10 + }, + show_validation_data: { + type: jsPsych.plugins.parameterType.BOOL, + default: false + } + } + } + + plugin.trial = function(display_element, trial) { + + var trial_data = {} + trial_data.raw_gaze = []; + trial_data.percent_in_roi = []; + trial_data.average_offset = []; + + var html = ` +
+
` + + display_element.innerHTML = html; + + var wg_container = display_element.querySelector('#webgazer-validate-container'); + + var points_completed = -1; + var val_points = null; + var start = performance.now(); + + validate(); + + function validate(){ + + if(trial.randomize_validation_order){ + val_points = jsPsych.randomization.shuffle(trial.validation_points); + } else { + val_points = trial.validation_points; + } + points_completed = -1; + jsPsych.extensions['webgazer'].resume(); + //jsPsych.extensions.webgazer.showPredictions(); + next_validation_point(); + } + + function next_validation_point(){ + points_completed++; + if(points_completed == val_points.length){ + validation_done(); + } else { + var pt = val_points[points_completed]; + validation_display(pt); + } + } + + function validation_display(pt){ + var pt_html = drawValidationPoint(pt[0], pt[1]); + wg_container.innerHTML = pt_html; + + var pt_dom = wg_container.querySelector('.validation-point'); + + var br = pt_dom.getBoundingClientRect(); + var x = br.left + br.width / 2; + var y = br.top + br.height / 2; + + var pt_start_val = performance.now() + trial.time_to_saccade; + var pt_finish = pt_start_val + trial.validation_duration; + + var pt_data = []; + + requestAnimationFrame(function watch_dot(){ + + if(performance.now() > pt_start_val){ + jsPsych.extensions['webgazer'].getCurrentPrediction().then(function(prediction){ + pt_data.push({dx: prediction.x - x, dy: prediction.y - y, t: Math.round(performance.now()-start)}); + }); + } + if(performance.now() < pt_finish){ + requestAnimationFrame(watch_dot); + } else { + trial_data.raw_gaze.push(pt_data); + next_validation_point(); + } + }); + + } + + function drawValidationPoint(x,y){ + if(trial.validation_point_coordinates == 'percent'){ + return drawValidationPoint_PercentMode(x,y); + } + if(trial.validation_point_coordinates == 'center-offset-pixels'){ + return drawValidationPoint_CenterOffsetMode(x,y); + } + } + + function drawValidationPoint_PercentMode(x,y){ + return `
` + } + + function drawValidationPoint_CenterOffsetMode(x,y){ + return `
` + } + + function drawCircle(target_x, target_y, dx, dy, r){ + if(trial.validation_point_coordinates == 'percent'){ + return drawCircle_PercentMode(target_x, target_y, dx, dy, r); + } + if(trial.validation_point_coordinates == 'center-offset-pixels'){ + return drawCircle_CenterOffsetMode(target_x, target_y, dx, dy, r); + } + } + + function drawCircle_PercentMode(target_x, target_y, dx, dy, r){ + var html = ` +
+ ` + return html; + } + + function drawCircle_CenterOffsetMode(target_x, target_y, dx, dy, r){ + var html = ` +
+ ` + return html; + } + + function drawRawDataPoint(target_x, target_y, dx, dy, ){ + if(trial.validation_point_coordinates == 'percent'){ + return drawRawDataPoint_PercentMode(target_x, target_y, dx, dy); + } + if(trial.validation_point_coordinates == 'center-offset-pixels'){ + return drawRawDataPoint_CenterOffsetMode(target_x, target_y, dx, dy); + } + } + + function drawRawDataPoint_PercentMode(target_x, target_y, dx, dy){ + var color = Math.sqrt(dx*dx + dy*dy) <= trial.roi_radius ? '#afa' : '#faa'; + return `
` + } + + function drawRawDataPoint_CenterOffsetMode(target_x, target_y, dx, dy){ + var color = Math.sqrt(dx*dx + dy*dy) <= trial.roi_radius ? '#afa' : '#faa'; + return `
` + } + + function median(arr){ + var mid = Math.floor(arr.length/2); + var sorted_arr = arr.sort((a,b) => a-b); + if(arr.length % 2 == 0){ + return sorted_arr[mid-1] + sorted_arr[mid] / 2; + } else { + return sorted_arr[mid]; + } + } + + function calculateGazeCentroid(gazeData){ + + var x_diff_m = gazeData.reduce(function(accumulator, currentValue, index){ + accumulator += currentValue.dx; + if(index == gazeData.length-1){ + return accumulator / gazeData.length; + } else { + return accumulator; + } + }, 0); + + var y_diff_m = gazeData.reduce(function(accumulator, currentValue, index){ + accumulator += currentValue.dy; + if(index == gazeData.length-1){ + return accumulator / gazeData.length; + } else { + return accumulator; + } + }, 0); + + var median_distance = median(gazeData.map(function(x){ return(Math.sqrt(Math.pow(x.dx-x_diff_m,2) + Math.pow(x.dy-y_diff_m,2)))})); + + return { + x: x_diff_m, + y: y_diff_m, + r: median_distance + } + } + + function calculatePercentInROI(gazeData){ + var distances = gazeData.map(function(p){ + return(Math.sqrt(Math.pow(p.dx,2) + Math.pow(p.dy,2))) + }); + var sum_in_roi = distances.reduce(function(accumulator, currentValue){ + if(currentValue <= trial.roi_radius){ + accumulator++; + } + return accumulator; + }, 0); + var percent = sum_in_roi / gazeData.length * 100; + return percent; + } + + function calculateSampleRate(gazeData){ + var mean_diff = []; + for(var i=0; i 1){ + var t_diff = []; + for(var j=1; j 0){ + return 1000 / (mean_diff.reduce(function(a,b) { return(a+b) }, 0) / mean_diff.length); + } else { + return null; + } + + } + + function validation_done(){ + trial_data.samples_per_sec = calculateSampleRate(trial_data.raw_gaze).toFixed(2); + for(var i=0; i', { - src: trial.x_path, - "class": 'jspsych-xab-stimulus' - })); - } - else { - display_element.append($('
', { - "class": 'jspsych-xab-stimulus', - html: trial.x_path - })); - } - - // start a timer of length trial.timing_x to move to the next part of the trial - setTimeout(function() { - plugin.trial(display_element, block, trial, part + 1); - }, trial.timing_x); - break; - - // the second part of the trial is the gap between X and AB. - case 2: - // remove the x stimulus - $('.jspsych-xab-stimulus').remove(); - - // start timer - setTimeout(function() { - plugin.trial(display_element, block, trial, part + 1); - }, trial.timing_xab_gap); - break; - - // the third part of the trial is to display A and B, and get the subject's response - case 3: - - // randomize whether the target is on the left or the right - var images = [trial.a_path, trial.b_path]; - var target_left = (Math.floor(Math.random() * 2) === 0); // 50% chance target is on left. - if (!target_left) { - images = [trial.b_path, trial.a_path]; - } - - // show the options - if (!trial.is_html) { - display_element.append($('', { - "src": images[0], - "class": 'jspsych-xab-stimulus left' - })); - display_element.append($('', { - "src": images[1], - "class": 'jspsych-xab-stimulus right' - })); - } - else { - display_element.append($('
', { - "class": 'jspsych-xab-stimulus left', - html: images[0] - })); - display_element.append($('
', { - "class": 'jspsych-xab-stimulus right', - html: images[1] - })); - } - - if (trial.prompt !== "") { - display_element.append(trial.prompt); - } - - // if timing_ab is > 0, then we hide the stimuli after timing_ab milliseconds - if (trial.timing_ab > 0) { - setTimeout(function() { - if (!xab_trial_complete) { - $('.jspsych-xab-stimulus').css('visibility', 'hidden'); - } - }, trial.timing_ab); - } - - // create the function that triggers when a key is pressed. - var after_response = function(info) { - - var correct = false; // true when the correct response is chosen - - if (info.key == trial.left_key) // 'q' key by default - { - if (target_left) { - correct = true; - } - } - else if (info.key == trial.right_key) // 'p' key by default - { - if (!target_left) { - correct = true; - } - } - - - // create object to store data from trial - var trial_data = { - "trial_type": "xab", - "trial_index": block.trial_idx, - "rt": info.rt, - "correct": correct, - "stimulus_x": trial.x_path, - "stimulus_a": trial.a_path, - "stimulus_b": trial.b_path, - "key_press": info.key - }; - block.writeData($.extend({}, trial_data, trial.data)); - - display_element.html(''); // remove all - - xab_trial_complete = true; - - // move on to the next trial after timing_post_trial milliseconds - if(trial.timing_post_trial > 0) { - setTimeout(function() { - block.next(); - }, trial.timing_post_trial); - } else { - block.next(); - } - - }; - - jsPsych.pluginAPI.getKeyboardResponse(after_response, [trial.left_key, trial.right_key], 'date', false); - - break; - } - }; - - return plugin; - })(); -})(jQuery); diff --git a/scripts/plugins/template/jspsych-plugin-template.js b/scripts/plugins/template/jspsych-plugin-template.js index 22e3430..db0956f 100644 --- a/scripts/plugins/template/jspsych-plugin-template.js +++ b/scripts/plugins/template/jspsych-plugin-template.js @@ -1,65 +1,35 @@ -/** - * Josh de Leeuw - * November 2013 - * - * This is a basic template for a jsPsych plugin. Use it to start creating your - * own plugin. There is more information about how to create a plugin on the - * jsPsych wiki (https://github.com/jodeleeuw/jsPsych/wiki/Create-a-Plugin). - * - * +/* + * Example plugin template */ - -(function( $ ) { - jsPsych["PLUGIN-NAME"] = (function(){ - var plugin = {}; +jsPsych.plugins["PLUGIN-NAME"] = (function() { - plugin.create = function(params) { - var trials = new Array(NUMBER_OF_TRIALS); - for(var i = 0; i < NUMBER_OF_TRIALS; i++) - { - trials[i] = {}; - trials[i].type = "PLUGIN-NAME"; - // other information needed for the trial method can be added here - - // supporting the generic data object with the following line - // is always a good idea. it allows people to pass in the data - // parameter, but if they don't it gracefully adds an empty object - // in it's place. - trials[i].data = (typeof params.data === 'undefined') ? {} : params.data[i]; - } - return trials; - }; + var plugin = {}; - plugin.trial = function(display_element, block, trial, part) { - // code for running the trial goes here - - // allow variables as functions - // this allows any trial variable to be specified as a function - // that will be evaluated when the trial runs. this allows users - // to dynamically adjust the contents of a trial as a result - // of other trials, among other uses. you can leave this out, - // but in general it should be included - trial = jsPsych.normalizeTrialVariables(trial); - - // data saving - // this is technically optional, but virtually every plugin will - // need to do it. it is good practice to include the type and - // trial_index fields for all plugins. - var trial_data = { - type: trial.type, - trial_index: block.trial_idx, - // other values to save go here - }; - - // this line merges together the trial_data object and the generic - // data object (trial.data), and then stores them. - block.writeData($.extend({}, trial_data, trial.data)); - - // this method must be called at the end of the trial - block.next(); - }; + plugin.info = { + name: "PLUGIN-NAME", + parameters: { + parameter_name: { + type: jsPsych.plugins.parameterType.INT, // BOOL, STRING, INT, FLOAT, FUNCTION, KEY, SELECT, HTML_STRING, IMAGE, AUDIO, VIDEO, OBJECT, COMPLEX + default: undefined + }, + parameter_name: { + type: jsPsych.plugins.parameterType.IMAGE, + default: undefined + } + } + } - return plugin; - })(); -}) (jQuery); \ No newline at end of file + plugin.trial = function(display_element, trial) { + + // data saving + var trial_data = { + parameter_name: 'parameter value' + }; + + // end trial + jsPsych.finishTrial(trial_data); + }; + + return plugin; +})();