jsPsych/jspsych.js
2014-09-17 11:34:00 -04:00

1034 lines
24 KiB
JavaScript
Executable File

/**
* jspsych.js
* Josh de Leeuw
*
* documentation: https://github.com/jodeleeuw/jsPsych/wiki
*
**/
(function($) {
jsPsych = (function() {
var core = {};
//
// private 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 = 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($('<body>'));
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;
},
'show_progress_bar': false
};
// 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 = function() {
var all_data = [];
for (var i = 0; i < exp_blocks.length; i++) {
all_data[i] = exp_blocks[i].data;
}
return all_data;
};
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;
var current_trial_local = -1;
for (var i = 0; i < curr_block; i++) {
current_trial_global += exp_blocks[i].num_trials;
}
if (current_trial_global < total_trials) {
current_trial_global += exp_blocks[curr_block].trial_idx;
current_trial_local = 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": current_trial_local,
"current_block": curr_block
};
return obj;
};
core.startTime = function() {
return exp_start_time;
};
core.totalTime = function() {
return (new Date()).getTime() - exp_start_time.getTime();
};
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.getDisplayElement = function() {
return DOM_target;
}
// data storage object
var datastore = {};
datastore.data = [];
datastore.writeData = function(data_object, trial_type, trial_index) {
var progress = core.progress();
var default_data = {
'trial_type': trial_type,
'trial_index': trial_index,
'trial_index_global': progress.current_trial_global,
'time_elapsed': core.totalTime(),
'block_index': curr_block
};
var ext_data_object = $.extend({}, data_object, default_data);
this.data[this.trial_idx] = ext_data_object;
opts.on_data_update(ext_data_object);
}
//
// private functions //
//
function run() {
// take the experiment structure and create a set of blocks
exp_blocks = new Array(opts.experiment_structure.length);
// iterate through 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]);
// add options that are generic to all plugins
trials = addGenericTrialOptions(trials, opts.experiment_structure[i]);
exp_blocks[i] = createBlock(trials);
}
// show progress bar if requested
if (opts.show_progress_bar === true) {
drawProgressBar();
}
// record the start time
exp_start_time = new Date();
// begin! - run the first block
exp_blocks[0].next();
}
function addGenericTrialOptions(trials_arr, opts) {
// modify this list to add new generic parameters
var genericParameters = ['type', 'data', 'timing_post_trial', 'on_finish'];
// default values for generics above
var defaultValues = [, , 1000, ];
for (var i = 0; i < genericParameters.length; i++) {
trials_arr = addParamToTrialsArr(trials_arr, opts[genericParameters[i]], genericParameters[i], defaultValues[i]);
}
return trials_arr;
}
function addParamToTrialsArr(trials_arr, param, param_name, default_value) {
if (typeof default_value !== 'undefined') {
param = (typeof param === 'undefined') ? default_value : param;
}
if (typeof param !== 'undefined') {
if (Array.isArray(param)) {
// check if data object array is the same length as the number of trials
if (param.length != trials_arr.length) {
throw new Error('Invalid specification of parameter ' + param_name + ' in plugin type ' + trials_arr[i].type + '. Length of parameter array does not match the number of trials in the block.');
} else {
for (var i = 0; i < trials_arr.length; i++) {
trials_arr[i][param_name] = param[i];
}
}
} else {
// use the same data object for each trial
for (var i = 0; i < trials_arr.length; i++) {
trials_arr[i][param_name] = param;
}
}
}
return trials_arr;
}
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() {
// trial_idx is -1 when block is created, so to start the first trial, trial_idx will be -1.
if (this.trial_idx > -1) {
// handle callback at plugin level
if (typeof this.trials[this.trial_idx].on_finish === 'function') {
this.trials[this.trial_idx].on_finish(this.data[this.trial_idx]);
}
// handle callback at whole-experiment level
opts.on_trial_finish();
// update progress bar if shown
if (opts.show_progress_bar === true) {
updateProgressBar();
}
};
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) {
var progress = jsPsych.progress();
var default_data = {
'trial_type': this.trials[this.trial_idx].type,
'trial_index': this.trial_idx,
'trial_index_global': progress.current_trial_global,
'time_elapsed': jsPsych.totalTime(),
'block_index': curr_block
};
var ext_data_object = $.extend({}, data_object, default_data);
this.data[this.trial_idx] = ext_data_object;
opts.on_data_update(ext_data_object);
},
done: nextBlock,
num_trials: trial_list.length
};
return block;
}
function createChunk() {}
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);
}
function drawProgressBar() {
$('body').prepend($('<div id="jspsych-progressbar-container"><span>Completion Progress</span><div id="jspsych-progressbar-outer"><div id="jspsych-progressbar-inner"></div></div></div>'));
}
function updateProgressBar() {
var progress = jsPsych.progress();
var percentComplete = 100 * ((progress.current_trial_global + 1) / progress.total_trials);
$('#jspsych-progressbar-inner').css('width', percentComplete + "%");
}
return core;
})();
jsPsych.dataAPI = (function() {
var module = {};
// 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.
module.dataAsCSV = function(append_data) {
var dataObj = jsPsych.data();
return JSON2CSV(flattenData(dataObj, append_data));
};
module.localSave = function(filename, format, append_data) {
var data_string;
if (format == 'JSON' || format == 'json') {
data_string = JSON.stringify(flattenData(jsPsych.data(), append_data));
} else if (format == 'CSV' || format == 'csv') {
data_string = module.dataAsCSV(append_data);
} else {
throw new Error('invalid format specified for jsPsych.dataAPI.localSave');
}
saveTextToFile(data_string, filename);
};
module.getTrialsOfType = function(trial_type) {
var data = jsPsych.data();
data = flatten(data);
var trials = [];
for (var i = 0; i < data.length; i++) {
if (data[i].trial_type == trial_type) {
trials.push(data[i]);
}
}
return trials;
};
module.displayData = function(format) {
format = (typeof format === 'undefined') ? "json" : format.toLowerCase();
if (format != "json" && format != "csv") {
console.log('Invalid format declared for displayData function. Using json as default.');
format = "json";
}
var data_string;
if (format == 'json') {
data_string = JSON.stringify(flattenData(jsPsych.data()), undefined, 1);
} else {
data_string = module.dataAsCSV();
}
var display_element = jsPsych.getDisplayElement();
display_element.append($('<pre>', {
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($('<a>', {
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++;
}
}
}
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;
}
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 {
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 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;
})();
jsPsych.pluginAPI = (function() {
// keyboard listeners
var keyboard_listeners = [];
var module = {};
module.getKeyboardResponse = function(callback_function, valid_responses, rt_method, persist) {
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_id;
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) {
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.
//
module.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
module.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++) {
if (typeof params[possible_arrays[i]] !== 'undefined') {
params[possible_arrays[i]] = ckArray(params[possible_arrays[i]]) ? params[possible_arrays[i]] : [params[possible_arrays[i]]];
}
}
return params;
};
function getKeys(obj) {
var r = [];
for (var k in obj) {
if (!obj.hasOwnProperty(k)) continue;
r.push(k);
}
return r;
}
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 out;
}
})(jQuery);