From 4366660be206b52fb346691eff82e939e89782a1 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Fri, 3 Mar 2023 11:21:51 -0500 Subject: [PATCH 1/9] initial draft of corsi-blocks plugin --- .../plugin-corsi-blocks/examples/example.html | 46 ++++ packages/plugin-corsi-blocks/jest.config.cjs | 1 + packages/plugin-corsi-blocks/package.json | 45 ++++ .../plugin-corsi-blocks/rollup.config.mjs | 3 + .../plugin-corsi-blocks/src/index.spec.ts | 19 ++ packages/plugin-corsi-blocks/src/index.ts | 253 ++++++++++++++++++ packages/plugin-corsi-blocks/tsconfig.json | 7 + 7 files changed, 374 insertions(+) create mode 100644 packages/plugin-corsi-blocks/examples/example.html create mode 100644 packages/plugin-corsi-blocks/jest.config.cjs create mode 100644 packages/plugin-corsi-blocks/package.json create mode 100644 packages/plugin-corsi-blocks/rollup.config.mjs create mode 100644 packages/plugin-corsi-blocks/src/index.spec.ts create mode 100644 packages/plugin-corsi-blocks/src/index.ts create mode 100644 packages/plugin-corsi-blocks/tsconfig.json diff --git a/packages/plugin-corsi-blocks/examples/example.html b/packages/plugin-corsi-blocks/examples/example.html new file mode 100644 index 00000000..a3b5fe64 --- /dev/null +++ b/packages/plugin-corsi-blocks/examples/example.html @@ -0,0 +1,46 @@ + + + + + + + + + + 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..3fb1e34a --- /dev/null +++ b/packages/plugin-corsi-blocks/package.json @@ -0,0 +1,45 @@ +{ + "name": "@jspsych-contrib/plugin-corsi-blocks", + "private": "true", + "version": "0.1.0", + "description": "", + "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.0.0" + }, + "devDependencies": { + "@jspsych/config": "^1.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + } +} 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..315f0c27 --- /dev/null +++ b/packages/plugin-corsi-blocks/src/index.spec.ts @@ -0,0 +1,19 @@ +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, + parameter_name: 1, + parameter_name2: "img.png", + }, + ]); + + 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..1607f2fc --- /dev/null +++ b/packages/plugin-corsi-blocks/src/index.ts @@ -0,0 +1,253 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +const info = { + name: "corsi-blocks", + parameters: { + sequence: { + type: ParameterType.INT, + default: undefined, + array: true, + }, + blocks: { + type: ParameterType.COMPLEX, + array: true, + default: undefined, + nested: { + x: { + type: ParameterType.INT, + default: undefined, + }, + y: { + type: ParameterType.INT, + default: undefined, + }, + }, + }, + block_size: { + type: ParameterType.INT, + default: 35, + }, + arena_width: { + type: ParameterType.INT, + default: 400, + }, + arena_height: { + type: ParameterType.INT, + default: 400, + }, + prompt: { + type: ParameterType.STRING, + default: null, + }, + mode: { + type: ParameterType.STRING, + default: "display", + options: ["display", "input"], + }, + sequence_iti: { + type: ParameterType.INT, + default: 250, + }, + sequence_duration: { + type: ParameterType.INT, + default: 1000, + }, + pre_stim_duration: { + type: ParameterType.INT, + default: 500, + }, + response_animation_duration: { + type: ParameterType.INT, + default: 500, + }, + block_color: { + type: ParameterType.STRING, + default: "#555", + }, + highlight_color: { + type: ParameterType.STRING, + default: "#ff0000", + }, + correct_color: { + type: ParameterType.STRING, + default: "#00ff00", + }, + incorrect_color: { + type: ParameterType.STRING, + default: "#ff0000", + }, + }, +}; + +type Info = typeof info; + +/** + * **corsi-blocks** + * + * Plugin to run the Corsi blocks task. + * + * @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 trial_data = { + sequence: trial.sequence, + response: [], + blocks: trial.blocks, + correct: null, + }; + + const end_trial = () => { + display_element.innerHTML = ""; + this.jsPsych.finishTrial(trial_data); + }; + + 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") { + if (sequence_location < trial.sequence.length) { + ( + document.querySelector( + `.jspsych-corsi-block[data-id="${trial.sequence[sequence_location]}"]` + ) as HTMLElement + ).style.backgroundColor = trial.highlight_color; + wait(update_display, trial.sequence_duration); + display_phase = "iti"; + } + if (sequence_location == trial.sequence.length) { + end_trial(); + } + } else if (display_phase == "iti") { + ( + document.querySelector( + `.jspsych-corsi-block[data-id="${trial.sequence[sequence_location]}"]` + ) as HTMLElement + ).style.backgroundColor = trial.block_color; + sequence_location++; + wait(update_display, trial.sequence_iti); + display_phase = "sequence"; + } + }; + + 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)); + }; + + 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 + } + trial_data.response.push(parseInt(id)); + const correct = parseInt(id) == trial.sequence[trial_data.response.length - 1]; + if (correct) { + document + .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 { + document + .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"] +} From 5c009992e3a92f11034a5c83d833489135310796 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Mon, 6 Mar 2023 11:38:11 -0500 Subject: [PATCH 2/9] add checks now that jsPsych@7.3.2 fixes default param issue --- package-lock.json | 34 ++++++++++- packages/plugin-corsi-blocks/package.json | 4 +- .../plugin-corsi-blocks/src/index.spec.ts | 20 ++++++- packages/plugin-corsi-blocks/src/index.ts | 58 ++++++++++--------- 4 files changed, 82 insertions(+), 34 deletions(-) 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/package.json b/packages/plugin-corsi-blocks/package.json index 3fb1e34a..34fdcbb2 100644 --- a/packages/plugin-corsi-blocks/package.json +++ b/packages/plugin-corsi-blocks/package.json @@ -35,11 +35,11 @@ }, "homepage": "https://github.com/jspsych/jspsych-contrib/tree/main/packages/plugin-corsi-blocks", "peerDependencies": { - "jspsych": ">=7.0.0" + "jspsych": ">=7.3.2" }, "devDependencies": { "@jspsych/config": "^1.0.0", "@jspsych/test-utils": "^1.0.0", - "jspsych": "^7.0.0" + "jspsych": "^7.3.2" } } diff --git a/packages/plugin-corsi-blocks/src/index.spec.ts b/packages/plugin-corsi-blocks/src/index.spec.ts index 315f0c27..146b7f41 100644 --- a/packages/plugin-corsi-blocks/src/index.spec.ts +++ b/packages/plugin-corsi-blocks/src/index.spec.ts @@ -9,11 +9,27 @@ describe("corsi-blocks plugin", () => { const { expectFinished, getHTML, getData, displayElement, jsPsych } = await startTimeline([ { type: jsPsychCorsiBlocks, - parameter_name: 1, - parameter_name2: "img.png", + 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 index 1607f2fc..c90b7b7f 100644 --- a/packages/plugin-corsi-blocks/src/index.ts +++ b/packages/plugin-corsi-blocks/src/index.ts @@ -11,7 +11,13 @@ const info = { blocks: { type: ParameterType.COMPLEX, array: true, - default: undefined, + default: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + { x: 20, y: 20 }, + { x: 30, y: 30 }, + { x: 40, y: 40 }, + ], nested: { x: { type: ParameterType.INT, @@ -149,6 +155,20 @@ class CorsiBlocksPlugin implements JsPsychPlugin { 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"; @@ -158,12 +178,11 @@ class CorsiBlocksPlugin implements JsPsychPlugin { 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) { - ( - document.querySelector( - `.jspsych-corsi-block[data-id="${trial.sequence[sequence_location]}"]` - ) as HTMLElement - ).style.backgroundColor = trial.highlight_color; + block.style.backgroundColor = trial.highlight_color; wait(update_display, trial.sequence_duration); display_phase = "iti"; } @@ -171,31 +190,16 @@ class CorsiBlocksPlugin implements JsPsychPlugin { end_trial(); } } else if (display_phase == "iti") { - ( - document.querySelector( - `.jspsych-corsi-block[data-id="${trial.sequence[sequence_location]}"]` - ) as HTMLElement - ).style.backgroundColor = trial.block_color; + 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_iti); display_phase = "sequence"; } }; - 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)); - }; - window.requestAnimationFrame(update_display); } @@ -224,7 +228,7 @@ class CorsiBlocksPlugin implements JsPsychPlugin { trial_data.response.push(parseInt(id)); const correct = parseInt(id) == trial.sequence[trial_data.response.length - 1]; if (correct) { - document + display_element .querySelector(`.jspsych-corsi-block[data-id="${id}"]`) .animate(correct_animation, animation_timing); if (trial_data.response.length == trial.sequence.length) { @@ -232,7 +236,7 @@ class CorsiBlocksPlugin implements JsPsychPlugin { setTimeout(end_trial, trial.response_animation_duration); // allows animation to finish } } else { - document + display_element .querySelector(`.jspsych-corsi-block[data-id="${id}"]`) .animate(incorrect_animation, animation_timing); trial_data.correct = false; From 7d41129389d7069f7261df50cc7c4f108d65217d Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Mon, 6 Mar 2023 11:57:41 -0500 Subject: [PATCH 3/9] add changeset --- .changeset/rare-dolphins-approve.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rare-dolphins-approve.md 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. From 3d785af1a24d8fc230ebc390e5323d751c09ef59 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Mon, 6 Mar 2023 11:57:52 -0500 Subject: [PATCH 4/9] add link to main readme --- README.md | 1 + 1 file changed, 1 insertion(+) 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. From 85a9d50eacef80816bd7eb9c3389d53dd0465d96 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Mon, 6 Mar 2023 11:58:02 -0500 Subject: [PATCH 5/9] add package readme --- packages/plugin-corsi-blocks/README.md | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/plugin-corsi-blocks/README.md diff --git a/packages/plugin-corsi-blocks/README.md b/packages/plugin-corsi-blocks/README.md new file mode 100644 index 00000000..501c9f7e --- /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 + + - +