var jsPsychFreeSort = (function (jspsych) { 'use strict'; const info = { name: "free-sort", parameters: { /** Array of images to be displayed and sorted. */ stimuli: { type: jspsych.ParameterType.IMAGE, pretty_name: "Stimuli", default: undefined, array: true, }, /** Height of items in pixels. */ stim_height: { type: jspsych.ParameterType.INT, pretty_name: "Stimulus height", default: 100, }, /** Width of items in pixels */ stim_width: { type: jspsych.ParameterType.INT, pretty_name: "Stimulus width", default: 100, }, /** How much larger to make the stimulus while moving (1 = no scaling) */ scale_factor: { type: jspsych.ParameterType.FLOAT, pretty_name: "Stimulus scaling factor", default: 1.5, }, /** The height in pixels of the container that subjects can move the stimuli in. */ sort_area_height: { type: jspsych.ParameterType.INT, pretty_name: "Sort area height", default: 700, }, /** The width in pixels of the container that subjects can move the stimuli in. */ sort_area_width: { type: jspsych.ParameterType.INT, pretty_name: "Sort area width", default: 700, }, /** The shape of the sorting area */ sort_area_shape: { type: jspsych.ParameterType.SELECT, pretty_name: "Sort area shape", options: ["square", "ellipse"], default: "ellipse", }, /** HTML to display above/below the sort area. It can be used to provide a reminder about the action the subject is supposed to take. */ prompt: { type: jspsych.ParameterType.HTML_STRING, pretty_name: "Prompt", default: "", }, /** Indicates whether to show prompt "above" or "below" the sorting area. */ prompt_location: { type: jspsych.ParameterType.SELECT, pretty_name: "Prompt location", options: ["above", "below"], default: "above", }, /** The text that appears on the button to continue to the next trial. */ button_label: { type: jspsych.ParameterType.STRING, pretty_name: "Button label", default: "Continue", }, /** * 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. */ change_border_background_color: { type: jspsych.ParameterType.BOOL, pretty_name: "Change border background color", default: true, }, /** * 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_in: { type: jspsych.ParameterType.STRING, pretty_name: "Border color - in", default: "#a1d99b", }, /** * 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_color_out: { type: jspsych.ParameterType.STRING, pretty_name: "Border color - out", default: "#fc9272", }, /** The width in pixels of the border around the sort area. If null, the border width defaults to 3% of the sort area height. */ border_width: { type: jspsych.ParameterType.INT, pretty_name: "Border width", default: null, }, /** * 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_unfinished: { type: jspsych.ParameterType.HTML_STRING, pretty_name: "Counter text unfinished", default: "You still need to place %n% item%s% inside the sort area.", }, /** Text that will take the place of the counter_text_unfinished text when all items have been moved inside the sort area. */ counter_text_finished: { type: jspsych.ParameterType.HTML_STRING, pretty_name: "Counter text finished", default: "All items placed. Feel free to reposition items if necessary.", }, /** * 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. */ stim_starts_inside: { type: jspsych.ParameterType.BOOL, pretty_name: "Stim starts inside", default: false, }, /** * 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. */ column_spread_factor: { type: jspsych.ParameterType.FLOAT, pretty_name: "column spread factor", default: 1, }, }, }; /** * **free-sort** * * jsPsych plugin for drag-and-drop sorting of a collection of images * * @author Josh de Leeuw * @see {@link https://www.jspsych.org/plugins/jspsych-free-sort/ free-sort plugin documentation on jspsych.org} */ class FreeSortPlugin { constructor(jsPsych) { this.jsPsych = jsPsych; } trial(display_element, trial) { var start_time = performance.now(); // can't change trial properties (const), so create new variables for properties that might need to be changed var border_color_out = trial.border_color_out; var border_width = trial.border_width; var stimuli = trial.stimuli; if (trial.change_border_background_color == false) { border_color_out = "#000000"; } if (trial.border_width == null) { border_width = trial.sort_area_height * 0.03; } let html = "
'; // another div for border html += "
' + get_counter_text(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(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) * 0.5) { //r_coords.push({ x:x, y:y } ) r_coords.push({ x: x + trial.sort_area_width * (0.5 * trial.column_spread_factor), y: y, }); } else { l_coords.push({ x: x - trial.sort_area_width * (0.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 < 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 stimuli = shuffle(stimuli); } let inside = []; for (let i = 0; i < 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 * 0.5)]; } else { coords = l_coords[Math.floor(i * 0.5)]; } } display_element.querySelector("#jspsych-free-sort-arena").innerHTML += "' + ""; init_locations.push({ src: 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 < draggables.length; i++) { draggables[i].addEventListener(start_event_name, (event) => { let pageX; let pageY; if (event instanceof MouseEvent) { pageX = event.pageX; pageY = event.pageY; } //if (typeof document.ontouchend !== "undefined") { if (event instanceof TouchEvent) { // for touch devices event.preventDefault(); const touchObject = event.changedTouches[0]; pageX = touchObject.pageX; pageY = touchObject.pageY; } let elem = event.currentTarget; let x = pageX - elem.offsetLeft; let y = pageY - elem.offsetTop - window.scrollY; elem.style.transform = "scale(" + trial.scale_factor + "," + trial.scale_factor + ")"; let move_event = (e) => { let clientX = e.clientX; let clientY = e.clientY; if (typeof document.ontouchend !== "undefined") { // for touch devices const touchObject = e.changedTouches[0]; clientX = touchObject.clientX; clientY = touchObject.clientY; } cur_in = inside_ellipse(clientX - x, clientY - y, trial.sort_area_width * 0.5 - trial.stim_width * 0.5, trial.sort_area_height * 0.5 - trial.stim_height * 0.5, trial.sort_area_width * 0.5, trial.sort_area_height * 0.5, trial.sort_area_shape == "square"); elem.style.top = Math.min(trial.sort_area_height - trial.stim_height * 0.5, Math.max(-trial.stim_height * 0.5, clientY - y)) + "px"; elem.style.left = Math.min(trial.sort_area_width * 1.5 - trial.stim_width, Math.max(-trial.sort_area_width * 0.5, clientX - x)) + "px"; // modify border while items is being moved if (trial.change_border_background_color) { if (cur_in) { border.style.borderColor = trial.border_color_in; border.style.background = "None"; } else { border.style.borderColor = border_color_out; border.style.background = "None"; } } // replace in overall array, grab index from item id var elem_number = parseInt(elem.id.split("jspsych-free-sort-draggable-")[1], 10); inside.splice(elem_number, 1, cur_in); // 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); var end_event = (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 = 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); }); } display_element.querySelector("#jspsych-free-sort-done-btn").addEventListener("click", () => { if (inside.every(Boolean)) { const end_time = performance.now(); const rt = Math.round(end_time - start_time); // gather data const items = display_element.querySelectorAll(".jspsych-free-sort-draggable"); // get final position of all items let final_locations = []; for (let i = 0; i < items.length; i++) { final_locations.push({ src: items[i].dataset.src, x: parseInt(items[i].style.left), y: parseInt(items[i].style.top), }); } const trial_data = { init_locations: init_locations, moves: moves, final_locations: final_locations, rt: rt, }; // advance to next part display_element.innerHTML = ""; this.jsPsych.finishTrial(trial_data); } }); function get_counter_text(n) { var text_out = ""; var text_bits = trial.counter_text_unfinished.split("%"); for (var i = 0; i < text_bits.length; i++) { if (i % 2 === 0) { text_out += text_bits[i]; } else { if (text_bits[i] == "n") { text_out += n.toString(); } else if (text_bits[i] == "s" && n > 1) { text_out += "s"; } } } return text_out; } // helper functions function shuffle(array) { // define three variables let cur_idx = array.length, tmp_val, rand_idx; // While there remain elements to shuffle... while (0 !== cur_idx) { // Pick a remaining element... rand_idx = Math.floor(Math.random() * cur_idx); cur_idx -= 1; // And swap it with the current element. tmp_val = array[cur_idx]; array[cur_idx] = array[rand_idx]; array[rand_idx] = tmp_val; } return array; } function make_arr(startValue, stopValue, cardinality) { const step = (stopValue - startValue) / (cardinality - 1); let arr = []; for (let i = 0; i < cardinality; i++) { arr.push(startValue + step * i); } return arr; } function inside_ellipse(x, y, x0, y0, rx, ry, square = false) { if (square) { return Math.abs(x - x0) <= rx && Math.abs(y - y0) <= ry; } else { return ((x - x0) * (x - x0) * (ry * ry) + (y - y0) * (y - y0) * (rx * rx) <= rx * rx * (ry * ry)); } } function random_coordinate(max_width, max_height) { const rnd_x = Math.floor(Math.random() * (max_width - 1)); const rnd_y = Math.floor(Math.random() * (max_height - 1)); return { x: rnd_x, y: rnd_y, }; } } } FreeSortPlugin.info = info; return FreeSortPlugin; })(jsPsychModule);