diff --git a/.changeset/rare-dolphins-approve.md b/.changeset/rare-dolphins-approve.md new file mode 100644 index 00000000..b8657b85 --- /dev/null +++ b/.changeset/rare-dolphins-approve.md @@ -0,0 +1,5 @@ +--- +"@jspsych-contrib/plugin-corsi-blocks": major +--- + +Initial release of Corsi block plugin. This plugin can show a configurable display of blocks, highlight them in a specified sequence, and record a sequence of clicks by the user. diff --git a/README.md b/README.md index 80bc8aa6..fc6916ea 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Plugin/Extension | Contributor | Description ----------- | ----------- | ----------- [audio-multi-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-audio-multi-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an audio file using both button clicks and key presses. [audio-swipe-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-audio-swipe-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an audio file using swipe gestures and keyboard responses. +[corsi-blocks](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-corsi-blocks/README.md) | [Josh de Leeuw](https://github.com/jodeleeuw) | This plugin displays a configurable Corsi blocks task and records a series of click responses. [html-multi-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-multi-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an arbitrary HTML string using both button clicks and key presses. [html-swipe-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-swipe-response/README.md) | [Adam Richie-Halford](https://github.com/richford) | This plugin collects responses to an arbitrary HTML string using swipe gestures and keyboard responses. [html-vas-response](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-vas-response/README.md) | [Isaac Kinley](https://github.com/kinleyid) | This plugin collects responses to an arbitrary HTML string using a point-and-click visual analogue scale. diff --git a/package-lock.json b/package-lock.json index 407345e2..3504d017 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2629,6 +2629,10 @@ "resolved": "packages/plugin-copying-task", "link": true }, + "node_modules/@jspsych-contrib/plugin-corsi-blocks": { + "resolved": "packages/plugin-corsi-blocks", + "link": true + }, "node_modules/@jspsych-contrib/plugin-html-multi-response": { "resolved": "packages/plugin-html-multi-response", "link": true @@ -10184,9 +10188,10 @@ } }, "node_modules/jspsych": { - "version": "7.3.0", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/jspsych/-/jspsych-7.3.2.tgz", + "integrity": "sha512-pBLY3sHz13Q2TEIDz3uhDuitGXvH7eqwiHG+5GmoJyYCwnBdkv+tVXvJV0OAKMjb0oDelo7Ct2/dOFjX5sF/OQ==", "dev": true, - "license": "MIT", "dependencies": { "auto-bind": "^4.0.0", "random-words": "^1.1.1", @@ -15162,6 +15167,19 @@ "jspsych": "^7.3.0" } }, + "packages/plugin-corsi-blocks": { + "name": "@jspsych-contrib/plugin-corsi-blocks", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@jspsych/config": "^1.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.3.2" + }, + "peerDependencies": { + "jspsych": ">=7.3.2" + } + }, "packages/plugin-html-multi-response": { "name": "@jspsych-contrib/plugin-html-multi-response", "version": "1.0.2", @@ -17101,6 +17119,14 @@ "jspsych": "^7.3.0" } }, + "@jspsych-contrib/plugin-corsi-blocks": { + "version": "file:packages/plugin-corsi-blocks", + "requires": { + "@jspsych/config": "^1.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.3.2" + } + }, "@jspsych-contrib/plugin-html-multi-response": { "version": "file:packages/plugin-html-multi-response", "requires": { @@ -22260,7 +22286,9 @@ } }, "jspsych": { - "version": "7.3.0", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/jspsych/-/jspsych-7.3.2.tgz", + "integrity": "sha512-pBLY3sHz13Q2TEIDz3uhDuitGXvH7eqwiHG+5GmoJyYCwnBdkv+tVXvJV0OAKMjb0oDelo7Ct2/dOFjX5sF/OQ==", "dev": true, "requires": { "auto-bind": "^4.0.0", diff --git a/packages/plugin-corsi-blocks/README.md b/packages/plugin-corsi-blocks/README.md new file mode 100644 index 00000000..222d7baa --- /dev/null +++ b/packages/plugin-corsi-blocks/README.md @@ -0,0 +1,37 @@ +# corsi-blocks + +## Overview + +This plugin implements the Corsi block tapping task. It has two modes: a display mode and an input mode. In the display mode, the participant is shown a sequence of blocks. In the input mode, the participant is shown a sequence of blocks and must tap the blocks in the same order. Feedback can be provided after each responses. The number and arrangement of the blocks can be customized. + +## Loading + +### In browser + +```js + + + + + + + diff --git a/packages/plugin-corsi-blocks/jest.config.cjs b/packages/plugin-corsi-blocks/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/plugin-corsi-blocks/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-corsi-blocks/package.json b/packages/plugin-corsi-blocks/package.json new file mode 100644 index 00000000..38493ecc --- /dev/null +++ b/packages/plugin-corsi-blocks/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jspsych-contrib/plugin-corsi-blocks", + "version": "0.1.0", + "description": "Corsi blocks task plugin for jsPsych", + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "files": [ + "src", + "dist" + ], + "source": "src/index.ts", + "scripts": { + "test": "jest", + "test:watch": "npm test -- --watch", + "tsc": "tsc", + "build": "rollup --config", + "build:watch": "npm run build -- --watch" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jspsych/jspsych-contrib.git", + "directory": "packages/plugin-corsi-blocks" + }, + "author": "Josh de Leeuw", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jspsych-contrib/issues" + }, + "homepage": "https://github.com/jspsych/jspsych-contrib/tree/main/packages/plugin-corsi-blocks", + "peerDependencies": { + "jspsych": ">=7.3.2" + }, + "devDependencies": { + "@jspsych/config": "^1.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.3.2" + } +} diff --git a/packages/plugin-corsi-blocks/rollup.config.mjs b/packages/plugin-corsi-blocks/rollup.config.mjs new file mode 100644 index 00000000..a98c1e07 --- /dev/null +++ b/packages/plugin-corsi-blocks/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychCorsiBlocks"); diff --git a/packages/plugin-corsi-blocks/src/index.spec.ts b/packages/plugin-corsi-blocks/src/index.spec.ts new file mode 100644 index 00000000..146b7f41 --- /dev/null +++ b/packages/plugin-corsi-blocks/src/index.spec.ts @@ -0,0 +1,35 @@ +import { startTimeline } from "@jspsych/test-utils"; + +import jsPsychCorsiBlocks from "."; + +jest.useFakeTimers(); + +describe("corsi-blocks plugin", () => { + it("should load", async () => { + const { expectFinished, getHTML, getData, displayElement, jsPsych } = await startTimeline([ + { + type: jsPsychCorsiBlocks, + sequence: [0, 1, 2], + blocks: [ + { x: 10, y: 10 }, + { x: 20, y: 20 }, + { x: 30, y: 30 }, + ], + }, + ]); + + await jest.runAllTimers(); + + await expectFinished(); + }); + it("should work with default blocks", async () => { + const { expectFinished, getHTML, getData, displayElement, jsPsych } = await startTimeline([ + { + type: jsPsychCorsiBlocks, + sequence: [0, 1, 2], + }, + ]); + await jest.runAllTimers(); + await expectFinished(); + }); +}); diff --git a/packages/plugin-corsi-blocks/src/index.ts b/packages/plugin-corsi-blocks/src/index.ts new file mode 100644 index 00000000..63b2867a --- /dev/null +++ b/packages/plugin-corsi-blocks/src/index.ts @@ -0,0 +1,320 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +const info = { + name: "corsi-blocks", + parameters: { + /** + * An array of block indexes that specify the order of the sequence to be displayed. For example, + * [0, 1, 2, 3, 4] would display the first 5 blocks in the order they appear in the blocks parameter. + */ + sequence: { + type: ParameterType.INT, + default: undefined, + array: true, + }, + /** + * An array of objects that specify the x and y coordinates of each block. The coordinates represent the center + * of the block. The coordinates are specified as percentages of the width and height of the display. For example, + * {x: 50, y: 50} would place the block in the center of the display. + * + * The default value is an array of nine blocks that approximates the layout of the original Corsi blocks task. + */ + blocks: { + type: ParameterType.COMPLEX, + array: true, + default: [ + { y: 80, x: 45 }, + { y: 94, x: 80 }, + { y: 70, x: 20 }, + { y: 60, x: 70 }, + { y: 50, x: 35 }, + { y: 40, x: 6 }, + { y: 45, x: 94 }, + { y: 25, x: 60 }, + { y: 6, x: 47 }, + ], + nested: { + x: { + type: ParameterType.INT, + default: undefined, + }, + y: { + type: ParameterType.INT, + default: undefined, + }, + }, + }, + /** + * The size of the blocks as a percentage of the overall display size. + */ + block_size: { + type: ParameterType.INT, + default: 12, + }, + /** + * The width of the display, specified as a valid CSS measurement. + */ + display_width: { + type: ParameterType.STRING, + default: "400px", + }, + /** + * The height of the display, specified as a valid CSS measurement. + */ + display_height: { + type: ParameterType.STRING, + default: "400px", + }, + /** + * An optional text prompt that can be shown below the display area. + */ + prompt: { + type: ParameterType.STRING, + default: null, + }, + /** + * The mode of the trial. If 'display', then the sequence is displayed and the trial ends after + * the sequence is complete. If 'input', then the use must click on the blocks in the correct order. + */ + mode: { + type: ParameterType.STRING, + default: "display", + options: ["display", "input"], + }, + /** + * The duration, in milliseconds, between each block in the sequence. + */ + sequence_gap_duration: { + type: ParameterType.INT, + default: 250, + }, + /** + * The duration, in milliseconds, that each block is displayed during the sequence. + */ + sequence_block_duration: { + type: ParameterType.INT, + default: 1000, + }, + /** + * The duration, in milliseconds, to show the blocks before the sequence begins. + */ + pre_stim_duration: { + type: ParameterType.INT, + default: 500, + }, + /** + * The duration, in milliseconds, to show the feedback response animation + * during input mode. + */ + response_animation_duration: { + type: ParameterType.INT, + default: 500, + }, + /** + * The color of unselected, unhighlighted blocks. + */ + block_color: { + type: ParameterType.STRING, + default: "#555", + }, + /** + * The color of the highlighted block. + */ + highlight_color: { + type: ParameterType.STRING, + default: "#ff0000", + }, + /** + * The color of correct feedback. + */ + correct_color: { + type: ParameterType.STRING, + default: "#00ff00", + }, + /** + * The color of incorrect feedback. + */ + incorrect_color: { + type: ParameterType.STRING, + default: "#ff0000", + }, + }, +}; + +type Info = typeof info; + +/** + * **corsi-blocks** + * + * This plugin displays a sequence of blocks and then gets the + * subject's response. The sequence can be displayed in either + * 'display' mode or 'input' mode. In 'display' mode, the + * sequence is displayed and the trial ends after the sequence + * is complete. In 'input' mode, the subject must click on the + * blocks in the correct order. + * + * @author Josh de Leeuw + * @see {@link https://DOCUMENTATION_URL DOCUMENTATION LINK TEXT} + */ +class CorsiBlocksPlugin implements JsPsychPlugin { + static info = info; + + constructor(private jsPsych: JsPsych) {} + + trial(display_element: HTMLElement, trial: TrialType) { + let css = ``; + + let html = css; + html += '
'; + + for (let i = 0; i < trial.blocks.length; i++) { + html += `
`; + } + + if (trial.prompt != null) { + html += `

${trial.prompt}

`; + } + html += "
"; + + display_element.innerHTML = html; + + const start_time = performance.now(); + + const trial_data = { + sequence: trial.sequence, + response: [], + rt: [], + blocks: trial.blocks, + correct: null, + }; + + const end_trial = () => { + display_element.innerHTML = ""; + this.jsPsych.finishTrial(trial_data); + }; + + const wait = function (fn, t) { + const start = performance.now(); + + const _wait_help = (fn, t, s) => { + const duration = performance.now() - s; + if (duration >= t) { + fn(); + } else { + window.requestAnimationFrame(() => _wait_help(fn, t, start)); + } + }; + window.requestAnimationFrame(() => _wait_help(fn, t, start)); + }; + + if (trial.mode == "display") { + let sequence_location = 0; + let display_phase = "pre-stim"; + + const update_display = () => { + if (display_phase == "pre-stim") { + wait(update_display, trial.pre_stim_duration); + display_phase = "sequence"; + } else if (display_phase == "sequence") { + const block: HTMLElement = display_element.querySelector( + `.jspsych-corsi-block[data-id="${trial.sequence[sequence_location]}"]` + ); + if (sequence_location < trial.sequence.length) { + block.style.backgroundColor = trial.highlight_color; + wait(update_display, trial.sequence_block_duration); + display_phase = "iti"; + } + if (sequence_location == trial.sequence.length) { + end_trial(); + } + } else if (display_phase == "iti") { + const block: HTMLElement = display_element.querySelector( + `.jspsych-corsi-block[data-id="${trial.sequence[sequence_location]}"]` + ); + block.style.backgroundColor = trial.block_color; + sequence_location++; + wait(update_display, trial.sequence_gap_duration); + display_phase = "sequence"; + } + }; + + window.requestAnimationFrame(update_display); + } + + if (trial.mode == "input") { + const correct_animation = [ + { backgroundColor: trial.block_color }, + { backgroundColor: trial.correct_color, offset: 0.2 }, + { backgroundColor: trial.block_color }, + ]; + + const incorrect_animation = [ + { backgroundColor: trial.block_color }, + { backgroundColor: trial.incorrect_color, offset: 0.2 }, + { backgroundColor: trial.block_color }, + ]; + + const animation_timing = { + duration: trial.response_animation_duration, + iterations: 1, + }; + + const register_click = (id: string) => { + if (trial_data.correct !== null) { + return; // extra click during timeout, do nothing + } + const rt = Math.round(performance.now() - start_time); + trial_data.response.push(parseInt(id)); + trial_data.rt.push(rt); + const correct = parseInt(id) == trial.sequence[trial_data.response.length - 1]; + if (correct) { + display_element + .querySelector(`.jspsych-corsi-block[data-id="${id}"]`) + .animate(correct_animation, animation_timing); + if (trial_data.response.length == trial.sequence.length) { + trial_data.correct = true; + setTimeout(end_trial, trial.response_animation_duration); // allows animation to finish + } + } else { + display_element + .querySelector(`.jspsych-corsi-block[data-id="${id}"]`) + .animate(incorrect_animation, animation_timing); + trial_data.correct = false; + setTimeout(end_trial, trial.response_animation_duration); // allows animation to finish + } + }; + + var blocks = display_element.querySelectorAll(".jspsych-corsi-block"); + for (var i = 0; i < blocks.length; i++) { + blocks[i].addEventListener("click", (e) => { + register_click((e.target as HTMLElement).getAttribute("data-id")); + }); + } + } + } +} + +export default CorsiBlocksPlugin; diff --git a/packages/plugin-corsi-blocks/tsconfig.json b/packages/plugin-corsi-blocks/tsconfig.json new file mode 100644 index 00000000..3eabd0c2 --- /dev/null +++ b/packages/plugin-corsi-blocks/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.contrib.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}