/** * jspsych.js * Josh de Leeuw * * documentation: docs.jspsych.org * **/ window.jsPsych = (function() { var core = {}; // // 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_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; // enumerated variables for special parameter types core.ALL_KEYS = 'allkeys'; core.NO_KEYS = 'none'; // // public methods // core.init = function(options) { // reset variables timeline = null; global_trial_index = 0; current_trial = {}; current_trial_finished = false; paused = false; waiting = false; loaded = false; var defaults = { 'display_element': undefined, '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; }, 'on_interaction_data_update': function(data){ return undefined; }, 'exclusions': {}, 'show_progress_bar': false, 'auto_preload': true, 'max_load_time': 60000, 'fullscreen': false, 'default_iti': 1000 }; // 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 have no margin document.querySelector('html').style.height = '100%'; document.querySelector('body').style.margin = '0px'; opts.display_element = document.querySelector('body'); } else { // make sure that the display element exists on the page 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_target = document.querySelector('#jspsych-content'); // add CSS class to DOM_target opts.display_element.className += ' jspsych-display-element'; DOM_target.className += 'jspsych-content'; // create experiment timeline timeline = new TimelineNode({ timeline: opts.timeline }); // create listeners for user browser interaction jsPsych.data.createInteractionListeners(); // check exclusions before continuing checkExclusions(opts.exclusions, function(){ // success! user can continue... // start experiment, with or without preloading if(opts.auto_preload){ jsPsych.pluginAPI.autoPreload(timeline, startExperiment); if(opts.max_load_time > 0){ setTimeout(function(){ if(!loaded){ loadFail(); } }, opts.max_load_time); } } else { startExperiment(); } }, function(){ // fail. incompatible user. } ); }; core.progress = function() { var percent_complete = typeof timeline == 'undefined' ? 0 : timeline.percentComplete(); var obj = { "total_trials": typeof timeline == 'undefined' ? undefined : timeline.length(), "current_trial_global": global_trial_index, "percent_complete": percent_complete }; return obj; }; core.startTime = function() { return exp_start_time; }; core.totalTime = function() { if(typeof exp_start_time == 'undefined'){ return 0; } return (new Date()).getTime() - exp_start_time.getTime(); }; core.getDisplayElement = function() { return DOM_target; }; core.finishTrial = function(data) { if(current_trial_finished){ return; } current_trial_finished = true; // 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.getData({trial_index: global_trial_index})[0]; // handle callback at plugin level if (typeof current_trial.on_finish === 'function') { current_trial.on_finish(trial_data); } // handle callback at whole-experiment level opts.on_trial_finish(trial_data); // wait for iti if (typeof current_trial.timing_post_trial == 'undefined') { if (opts.default_iti > 0) { setTimeout(nextTrial, opts.default_iti); } else { nextTrial(); } } else { if (current_trial.timing_post_trial > 0) { setTimeout(nextTrial, current_trial.timing_post_trial); } 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){ return timeline.timelineVariable(varname); } core.addNodeToEndOfTimeline = function(new_timeline, preload_callback){ timeline.insert(new_timeline); if(opts.auto_preload){ jsPsych.pluginAPI.autoPreload(timeline, preload_callback); } else { preload_callback(); } } core.pauseExperiment = function(){ paused = true; } core.resumeExperiment = function(){ paused = false; if(waiting){ waiting = false; nextTrial(); } } function TimelineNode(parameters, parent, relativeID) { // a unique ID for this node, relative to the parent var relative_id; // store the parent for this node var parent_node; // parameters for the trial if the node contains a trial var trial_parameters; // parameters for nodes that contain timelines var timeline_parameters; // stores trial information on a node that contains a timeline // used for adding new trials var node_trial_data; // track progress through the node var progress = { current_location: -1, // where on the timeline (which timelinenode) current_variable_set: 0, // which set of variables to use from timeline_variables current_repetition: 0, // how many times through the variable set on this run of the node current_iteration: 0, // how many times this node has been revisited done: false } // reference to self var self = this; // recursively get the next trial to run. // if this node is a leaf (trial), then return the trial. // otherwise, recursively find the next trial in the child timeline. this.trial = function() { if (typeof timeline_parameters == 'undefined') { // returns a clone of the trial_parameters to // protect functions. return Object.assign({}, trial_parameters); } else { if (progress.current_location >= timeline_parameters.timeline.length) { return null; } else { return timeline_parameters.timeline[progress.current_location].trial(); } } } this.markCurrentTrialComplete = function() { if(typeof timeline_parameters == 'undefined'){ progress.done = true; } else { timeline_parameters.timeline[progress.current_location].markCurrentTrialComplete(); } } this.nextRepetiton = function() { this.setTimelineVariablesOrder(); progress.current_location = -1; progress.current_variable_set = 0; progress.current_repetition++; for (var i = 0; i < timeline_parameters.timeline.length; i++) { timeline_parameters.timeline[i].reset(); } } // set the order for going through the timeline variables array // TODO: this is where all the sampling options can be implemented this.setTimelineVariablesOrder = function() { var order = []; for(var i=0; i'+ '

The minimum width is '+mw+'px. Your current width is '+w+'px.

'+ '

The minimum height is '+mh+'px. Your current height is '+h+'px.

'; core.getDisplayElement().innerHTML = msg; } else { clearInterval(interval); core.getDisplayElement().innerHTML = ''; checkExclusions(exclusions, success, fail); } }, 100); return; // prevents checking other exclusions while this is being fixed } } // WEB AUDIO API if(typeof exclusions.audio !== 'undefined' && exclusions.audio) { if(window.hasOwnProperty('AudioContext') || window.hasOwnProperty('webkitAudioContext')){ // clear } else { clear = false; var msg = '

Your browser does not support the WebAudio API, which means that you will not '+ 'be able to complete the experiment.

Browsers that support the WebAudio API include '+ 'Chrome, Firefox, Safari, and Edge.

'; core.getDisplayElement().innerHTML = msg; fail(); return; } } // GO? if(clear){ success(); } } function drawProgressBar() { var content = document.querySelector('.jspsych-display-element').innerHTML; document.querySelector('.jspsych-display-element').innerHTML = '
'+ 'Completion Progress'+ '
'+ '
'+ '
' + content; } function updateProgressBar() { var progress = jsPsych.progress(); document.querySelector('#jspsych-progressbar-inner').style.width = progress.percent_complete + "%"; } return core; })(); jsPsych.plugins = { // enumerate possible parameter types for plugins parameterType: { BOOL: 0, STRING: 1, INT: 2, FLOAT: 3, FUNCTION: 4, KEYCODE: 5, SELECT: 6 } }; jsPsych.data = (function() { var module = {}; // data storage object var allData = []; // browser interaction event data var interactionData = []; // data properties for all trials var dataProperties = {}; // ignored data fields var ignoredProperties = []; // cache the query_string var query_string; module.getData = function(filters) { var data_clone = deepExtend([], allData); if(typeof filters == 'undefined'){ return data_clone; } // [{p1: v1, p2:v2}, {p1:v2}] // {p1: v1} if(!Array.isArray(filters)){ var f = deepExtend([], [filters]); } else { var f = deepExtend([], filters); } var filtered_data = []; for(var x=0; x < data_clone.length; x++){ var keep = false; for(var i=0; iclick to download'; document.getElementById('jspsych-download-as-text-link').click(); } // // A few helper functions to handle data format conversion // // this function based on code suggested by StackOverflow users: // http://stackoverflow.com/users/64741/zachary // http://stackoverflow.com/users/317/joseph-sturtevant function JSON2CSV(objArray) { var array = typeof objArray != 'object' ? JSON.parse(objArray) : objArray; var line = ''; var result = ''; var columns = []; var i = 0; for (var j = 0; j < array.length; j++) { for (var key in array[j]) { var keyString = key + ""; keyString = '"' + keyString.replace(/"/g, '""') + '",'; if (!columns.includes(key)) { columns[i] = key; line += keyString; i++; } } } line = line.slice(0, -1); result += line + '\r\n'; for (var i = 0; i < array.length; i++) { var line = ''; for (var j = 0; j < columns.length; j++) { var value = (typeof array[i][columns[j]] === 'undefined') ? '' : array[i][columns[j]]; 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 = repetions.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) { return shuffle(arr); } module.shuffleNoRepeats = function(arr, equalityTest) { // 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 - 2; 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.sample = function(arr, size, withReplacement) { if (withReplacement == false) { if (size > arr.length) { console.error("jsPsych.randomization.sample cannot take a sample " + "larger than the size of the set of items to sample from when " + "sampling without replacement."); } } var samp = []; var shuff_arr = shuffle(arr); for (var i = 0; i < size; i++) { if (!withReplacement) { samp.push(shuff_arr.pop()); } else { samp.push(shuff_arr[Math.floor(Math.random() * shuff_arr.length)]); } } 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