diff --git a/package-lock.json b/package-lock.json index 9d1d8168..fa191c12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10491,6 +10491,10 @@ "node": ">=8" } }, + "node_modules/plugin-free-sort-ordered": { + "resolved": "packages/plugin-free-sort-ordered", + "link": true + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -14026,6 +14030,22 @@ "jspsych": ">=7.1.0" } }, + "packages/plugin-free-sort-ordered": { + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@citation-js/core": "^0.7.14", + "@citation-js/plugin-bibtex": "^0.7.14", + "@citation-js/plugin-cff": "^0.6.1", + "@citation-js/plugin-software-formats": "^0.6.1", + "@jspsych/plugin-free-sort": "^2.1.0", + "jspsych": "^8.0.0" + }, + "devDependencies": { + "@jspsych/config": "^3.2.2", + "@jspsych/test-utils": "^1.0.0" + } + }, "packages/plugin-fullscreen": { "name": "@jspsych/plugin-fullscreen", "version": "2.1.0", diff --git a/packages/plugin-free-sort-ordered/examples/index.html b/packages/plugin-free-sort-ordered/examples/index.html index 9c6e367e..d1267b5e 100644 --- a/packages/plugin-free-sort-ordered/examples/index.html +++ b/packages/plugin-free-sort-ordered/examples/index.html @@ -6,8 +6,8 @@ - + --> + diff --git a/packages/plugin-free-sort-ordered/package.json b/packages/plugin-free-sort-ordered/package.json index 08b7a6dc..b94f1676 100644 --- a/packages/plugin-free-sort-ordered/package.json +++ b/packages/plugin-free-sort-ordered/package.json @@ -41,6 +41,7 @@ "homepage": "/packages/plugin-free-sort-ordered/README.md", "dependencies": { "jspsych": "^8.0.0", + "@jspsych/plugin-free-sort": "^2.1.0", "@citation-js/core": "^0.7.14", "@citation-js/plugin-software-formats": "^0.6.1", "@citation-js/plugin-bibtex": "^0.7.14", diff --git a/packages/plugin-free-sort-ordered/src/index.ts b/packages/plugin-free-sort-ordered/src/index.ts index 612c2a36..2cecae3e 100644 --- a/packages/plugin-free-sort-ordered/src/index.ts +++ b/packages/plugin-free-sort-ordered/src/index.ts @@ -1,44 +1,128 @@ +import * as FreeSortPluginUtils from "@jspsych/plugin-free-sort/src/utils"; import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; import { version } from "../package.json"; +import * as Utils from "./utils"; const info = { name: "plugin-free-sort-ordered", version: version, parameters: { - /** Each element of this array is an image path or svg code. */ + /** Each element of this array is an image path or SVG code. */ stimuli: { type: ParameterType.INT | ParameterType.HTML_STRING, default: undefined, array: true, }, - /** The height of the images in pixels */ + /** The height of the images in pixels. */ stim_height: { type: ParameterType.INT, default: 100, }, - /** The width of the images in pixels */ + /** The width of the images in pixels. */ stim_width: { type: ParameterType.INT, default: 100, }, + /** The correct order of the stimuli. */ + stim_order: { + type: ParameterType.INT, + default: undefined, + array: true, + }, /** How much larger to make the stimulus while moving (1 = no scaling). */ scale_factor: { type: ParameterType.FLOAT, default: 1.5, }, + /** The height of the container that the stimuli start in. */ + holding_area_height: { + type: ParameterType.INT, + default: 700, + }, + /** The width of the container that the stimuli start in. */ + holding_area_width: { + type: ParameterType.INT, + default: 700, + }, + /** This string can contain HTML markup. The intention is that it can be used to provide a reminder about the action the participant is supposed to take (e.g., which key to press). */ + prompt: { + type: ParameterType.HTML_STRING, + default: "", + }, + /** Indicates whether to show the prompt `"above"` or `"below"` the sorting area. */ + prompt_location: { + type: ParameterType.SELECT, + options: ["above", "below"], + default: "above", + }, + /** The text that appears on the button to continue to the next trial. */ + button_label: { + type: ParameterType.STRING, + default: "Continue", + }, + /** Whether to display counter indicating how many items are left to be sorted. */ + include_counter: { + type: ParameterType.BOOL, + default: true, + }, + /** + * Text to display when there are one or more items that still need to be placed in a box. + * 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: ParameterType.HTML_STRING, + default: "You still need to place %n% item%s% in a box.", + }, + /** Text that will take the place of the counter_text_unfinished text when all items have been moved inside a box. */ + counter_text_finished: { + type: ParameterType.HTML_STRING, + default: "All items placed. Feel free to reposition items if necessary.", + }, }, data: { - /** Provide a clear description of the data1 that could be used as documentation. We will eventually use these comments to automatically build documentation and produce metadata. */ - data1: { + /** An array containing objects representing the initial locations of all the stimuli in the sorting area. Each element in the array represents a stimulus, and has a "src", "x", and "y" value. "src" is the image path, and "x" and "y" are the object location. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */ + init_locations: { + type: ParameterType.STRING, + array: true, + }, + /** An array containing objects representing all of the moves the participant made when sorting. Each object represents a move. Each element in the array has a "src", "x", and "y" value. "src" is the image path, and "x" and "y" are the object location after the move. This will be encoded as a JSON string when data is saved using the `.json()` or `.csv()` functions. */ + moves: { + type: ParameterType.COMPLEX, + array: true, + nested: { + src: { + type: ParameterType.STRING, + }, + x: { + type: ParameterType.INT, + }, + y: { + type: ParameterType.INT, + }, + }, + }, + final_locations: { + type: ParameterType.COMPLEX, + array: true, + nested: { + src: { + type: ParameterType.STRING, + }, + x: { + type: ParameterType.INT, + }, + y: { + type: ParameterType.INT, + }, + }, + }, + /** The response time in milliseconds for the participant to finish all sorting. */ + rt: { type: ParameterType.INT, }, - /** Provide a clear description of the data2 that could be used as documentation. We will eventually use these comments to automatically build documentation and produce metadata. */ - data2: { - type: ParameterType.STRING, - }, }, - // When you run build on your plugin, citations will be generated here based on the information in the CITATION.cff file. citations: "__CITATIONS__", }; @@ -58,13 +142,242 @@ class FreeSortOrderedPlugin implements JsPsychPlugin { constructor(private jsPsych: JsPsych) {} trial(display_element: HTMLElement, trial: TrialType) { - // data saving - var trial_data = { - data1: 99, // Make sure this type and name matches the information for data1 in the data object contained within the info const. - data2: "hello world!", // Make sure this type and name matches the information for data2 in the data object contained within the info const. - }; - // end trial - this.jsPsych.finishTrial(trial_data); + var start_time = performance.now(); + + // holding area + const holding_area_html = ` +
`; + + // counter text if included + const counter_html = ` +

+ ${trial.include_counter ? trial.get_counter_text(trial.stimuli.length) : ""} +

`; + + // container for the boxes + let box_grid_html = ` +
`; + + // create boxes for each stimulus + for (let i = 0; i < trial.stimuli.length; i++) { + box_grid_html += ` +
`; + } + box_grid_html += "
"; + + // prompt text (and counter if included) + const prompt_counter_html = ` +
+ ${trial.prompt + (trial.include_counter ? counter_html : "")} +
`; + + // button to continue + const button_html = ` +
`; + + // combine all HTML + let html = + trial.prompt_location === "above" + ? prompt_counter_html + holding_area_html + box_grid_html + button_html + : holding_area_html + box_grid_html + prompt_counter_html + button_html; + + display_element.innerHTML = html; + + // store initial locations of stimuli + let init_locations = []; + + // store locations of the boxes + let boxCoordinates = []; + for (let i = 0; i < trial.stimuli.length; i++) { + const box = document.getElementById(`jspsych-free-sort-ordered-box-${i}`); + if (box) { + const rect = box.getBoundingClientRect(); + boxCoordinates.push({ + x: rect.left, // no need to adjust for scrolling because getBoundingClientRect() accounts for it + y: rect.top, + }); + } else { + console.error(`Box element with id jspsych-free-sort-ordered-box-${i} not found.`); + } + } + + // boxes as array of objects + const boxAreas = []; + boxCoordinates.forEach((boxCoord, i) => { + boxAreas.push({ + id: i, + left: boxCoord.x, + top: boxCoord.y, + width: trial.stim_width, + height: trial.stim_height, + }); + }); + + // place each stimulus in initial locations + for (let i = 0; i < trial.stimuli.length; i++) { + var coords = FreeSortPluginUtils.random_coordinate( + trial.holding_area_width - trial.stim_width, + trial.holding_area_height - trial.stim_height + ); + + // add stimuli and their initial locations to the display + display_element.querySelector("#jspsych-free-sort-ordered-holding-area").innerHTML += + "' + + ""; + + // add initial locations to the init_locations array + init_locations.push({ + src: trial.stimuli[i], + x: coords.x, + y: coords.y, + }); + } + + // moves within a trial + let moves = []; + + // are objects currently inside + let inside = new Array(trial.stimuli.length).fill(false); + + // button to finish sorting + const button: HTMLButtonElement = display_element.querySelector("#jspsych-free-sort-done-btn"); + + // save draggable items as array + const draggables = Array.prototype.slice.call( + display_element.querySelectorAll(".jspsych-free-sort-ordered-draggable") + ); + + // make each stimulus draggable by adding event listeners for when they are dragged and dropped + draggables.forEach((draggable, i) => { + draggable.addEventListener("pointerdown", function ({ clientX: pageX, clientY: pageY }) { + let x = pageX - this.offsetLeft; + let y = pageY - this.offsetTop - window.scrollY; + this.style.transform = "scale(" + trial.scale_factor + "," + trial.scale_factor + ")"; + + // on pointer move, check if the stimulus is inside a box and update its position + const on_pointer_move = ({ clientX, clientY }: PointerEvent) => { + inside[i] = Utils.inside_box(clientX - x, clientY - y, boxAreas); + this.style.top = + Math.min( + window.innerHeight - trial.stim_height, // Bottom boundary of the viewport + Math.max(0, clientY - y) // Top boundary of the viewport + ) + "px"; + + this.style.left = + Math.min( + window.innerWidth - trial.stim_width, // Right boundary of the viewport + Math.max(0, clientX - x) // Left boundary of the viewport + ) + "px"; + + // modify text and background if all items are inside + if (inside.every(Boolean)) { + button.style.visibility = "visible"; + display_element.querySelector("#jspsych-free-sort-ordered-counter").innerHTML = + trial.counter_text_finished; + } else { + button.style.visibility = "hidden"; + display_element.querySelector("#jspsych-free-sort-ordered-counter").innerHTML = + get_counter_text(inside.length - inside.filter(Boolean).length); + } + }; + document.addEventListener("pointermove", on_pointer_move); + + // on pointer up, remove the event listeners and save the move + const on_pointer_up = (e) => { + document.removeEventListener("pointermove", on_pointer_move); + this.style.transform = "scale(1, 1)"; + moves.push({ + src: this.dataset.src, + x: this.offsetLeft, + y: this.offsetTop, + }); + document.removeEventListener("pointerup", on_pointer_up); + }; + document.addEventListener("pointerup", on_pointer_up); + }); + }); + + display_element + .querySelector("#jspsych-free-sort-ordered-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-ordered-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, + }; + + this.jsPsych.finishTrial(trial_data); + } + }); + + function get_counter_text(n: number) { + 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; + } } } diff --git a/packages/plugin-free-sort-ordered/src/utils.ts b/packages/plugin-free-sort-ordered/src/utils.ts new file mode 100644 index 00000000..90ffd286 --- /dev/null +++ b/packages/plugin-free-sort-ordered/src/utils.ts @@ -0,0 +1,20 @@ +export type boxArea = { + id: string; + left: number; + top: number; + width: number; + height: number; +}; + +export function inside_box(x: number, y: number, box_areas: boxArea[]): string | null { + for (const box_area of box_areas) { + const { id, left, top, width, height } = box_area; + const right = left + width; + const bottom = top + height; + + if (x >= left && x <= right && y >= top && y <= bottom) { + return id; // point is inside this box + } + } + return null; // point not inside any box +}