// jspsych.js // // Josh de Leeuw // Percepts and Concepts Lab, Indiana University // // (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; // turk info var turk_info; // // 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'); } 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; } }; // import options opts = $.extend({}, defaults, options); // set target DOM_target = opts.display_element; // add CSS class to DOM_target DOM_target.addClass('jspsych-display-element'); run(); }; // 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. core.data = function(flatten) { var all_data = []; for (var i = 0; i < exp_blocks.length; i++) { all_data[i] = exp_blocks[i].data; } if (flatten === true) { all_data = flattenData(all_data); } return all_data; }; // core.dataAsCSV returns a CSV string that contains all of the data // append_data is an option map object that will append values // to every row. for example, if append_data = {"subject": 4}, // then a column called subject will be added to the data and // it will always have the value 4. core.dataAsCSV = function(append_data) { var dataObj = core.data(); return JSON2CSV(flattenData(dataObj, append_data)); }; core.saveCSVdata = function(filename, append_data) { var data_string = core.dataAsCSV(append_data); saveTextToFile(data_string, filename); }; // 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.progress = function() { var total_trials = 0; for (var i = 0; i < exp_blocks.length; i++) { total_trials += exp_blocks[i].num_trials; } 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; 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 }; return obj; }; // core.startTime() returns the Date object which represents the time that the experiment started. core.startTime = function() { return exp_start_time; }; // core.totalTime() returns the length of time in ms since the experiment began core.totalTime = function() { return (new Date()).getTime() - exp_start_time.getTime(); }; // 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.preloadImages = function(images, callback_complete, callback_load) { // flatten the images array images = flatten(images); var n_loaded = 0; var loadfn = (typeof callback_load === 'undefined') ? function() {} : callback_load; var finishfn = (typeof callback_complete === 'undefined') ? function() {} : callback_complete; for (var i = 0; i < images.length; i++) { var img = new Image(); img.onload = function() { n_loaded++; loadfn(n_loaded); if (n_loaded == images.length) { finishfn(); } }; img.src = images[i]; } }; // 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. core.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 { 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 core.submitToTurk = function(data) { var turkInfo = core.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; } // // These are public functions, intended to be used for developing plugins. // They aren't considered part of the normal API for the core library. // core.normalizeTrialVariables = function(trial, protect) { protect = (typeof protect === 'undefined') ? [] : protect; var keys = getKeys(trial); var tmp = {}; for (var i = 0; i < keys.length; i++) { var process = true; for (var j = 0; j < protect.length; j++) { if (protect[j] == keys[i]) { process = false; break; } } if (typeof trial[keys[i]] == "function" && process) { tmp[keys[i]] = trial[keys[i]].call(); } else { tmp[keys[i]] = trial[keys[i]]; } } return tmp; }; // if possible_array is not an array, then return a one-element array // containing possible_array core.enforceArray = function(params, possible_arrays) { // 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]'; }; for (var i = 0; i < possible_arrays.length; i++) { params[possible_arrays[i]] = ckArray(params[possible_arrays[i]]) ? params[possible_arrays[i]] : [params[possible_arrays[i]]]; } return params; }; core.getKeyboardResponse = function(callback_function, valid_responses, rt_method) { rt_method = (typeof rt_method === 'undefined') ? 'date' : rt_method; if (rt_method != 'date' && rt_method != 'performance') { console.log('Invalid RT method specified in getKeyboardResponse. Defaulting to "date" method.'); rt_method = 'date'; } var start_time; if (rt_method == 'date') { start_time = (new Date()).getTime(); } if (rt_method == 'performance') { start_time = performance.now(); } var listener_function = function(e) { var key_time; if (rt_method == 'date') { key_time = (new Date()).getTime(); } if (rt_method == 'performance') { key_time = performance.now(); } 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; } } if (valid_response) { $(document).unbind('keydown', listener_function); callback_function({ key: e.which, rt: key_time - start_time }); } }; $(document).keydown(listener_function); }; // // private functions // // function run() { // take the experiment structure and dynamically create a set of blocks exp_blocks = new Array(opts.experiment_structure.length); // iterate through block list to create trials for (var i = 0; i < exp_blocks.length; i++) { // check to make sure plugin is loaded var plugin_name = opts.experiment_structure[i].type; if (typeof jsPsych[plugin_name] == 'undefined') { throw new Error("Failed attempt to create trials using plugin type " + plugin_name + ". Is the plugin loaded?"); } var trials = jsPsych[plugin_name]["create"].call(null, opts["experiment_structure"][i]); exp_blocks[i] = createBlock(trials); } // record the start time exp_start_time = new Date(); // begin! - run the first block exp_blocks[0].next(); } 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, trials: trial_list, data: [], next: function() { // 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]; if (typeof curr_trial == "undefined") { return this.done(); } // call on_trial_start() opts.on_trial_start(); do_trial(this, curr_trial); }, writeData: function(data_object) { this.data[this.trial_idx] = data_object; opts.on_data_update(data_object); }, done: nextBlock, num_trials: trial_list.length }; return block; } function finishExperiment() { opts["on_finish"].apply((new Object()), [core.data()]); } function do_trial(block, trial) { // execute trial method jsPsych[trial.type]["trial"].call(this, DOM_target, block, trial, 1); } // // 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++; } } } 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; } 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); } DOM_target.append($('', { id: 'jspsych-download-as-text-link', href: blobURL, css: { display: 'none' }, download: filename, html: 'download file' })); $('#jspsych-download-as-text-link')[0].click(); } function getKeys(obj) { var r = []; for (var k in obj) { if (!obj.hasOwnProperty(k)) continue; r.push(k); } return r; } // 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 out; } // 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 }; return core; })(); jsPsych.randomization = (function() { 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 randomizeSimpleSample. 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) { // throw warning if repetitions is too short, // throw warning if too long, and then use the first N } } } // 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++){ allsamples.push(array[i]); } } var out = shuffle(allsamples); if(unpack) { out = unpackArray(out); } return shuffle(out); } 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($.extend({}, 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; } function unpackArray(array) { var out = {}; for(var i = 0; i < array.length; i++){ var keys = Object.keys(array[i]); for(var k = 0; k < keys.length; k++){ if(typeof out[keys[k]] === 'undefined') { out[keys[k]] = []; } out[keys[k]].push(array[i][keys[k]]); } } return out; } function shuffle(array) { var m = array.length, t, i; // While there remain elements to shuffle… while (m) { // Pick a remaining element… i = Math.floor(Math.random() * m--); // And swap it with the current element. t = array[m]; array[m] = array[i]; array[i] = t; } return array; } return module; })(); })(jQuery);