diff --git a/.changeset/strange-baboons-protect.md b/.changeset/strange-baboons-protect.md new file mode 100644 index 00000000..6b64558a --- /dev/null +++ b/.changeset/strange-baboons-protect.md @@ -0,0 +1,5 @@ +--- +"@jspsych-contrib/extension-countdown": major +--- + +Added countdown extension. diff --git a/README.md b/README.md index 8d166a23..4a4a5a9b 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,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. +[countdown](https://github.com/jspsych/jspsych-contrib/blob/main/packages/extension-countdown/README.md) | [Shaobin Jiang](https://github.com/Shaobin-Jiang) | This extension adds a countdown during a trial. [gamepad](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-gamepad/README.md) | [Shaobin Jiang](https://github.com/Shaobin-Jiang) | This plugin allows one to use gamepads in a jsPsych experiment. [html-choice](https://github.com/jspsych/jspsych-contrib/blob/main/packages/plugin-html-choice/README.md) | [Younes Strittmatter](https://github.com/younesStrittmatter) | This plugin displays clickable html elements that can be used to present a choice. [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. diff --git a/package-lock.json b/package-lock.json index 1db05357..1cdc7ef3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3095,6 +3095,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jspsych-contrib/extension-countdown": { + "resolved": "packages/extension-countdown", + "link": true + }, "node_modules/@jspsych-contrib/extension-device-motion": { "resolved": "packages/extension-device-motion", "link": true @@ -16536,6 +16540,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/extension-countdown": { + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + }, + "peerDependencies": { + "jspsych": ">=7.0.0" + } + }, "packages/extension-device-motion": { "name": "@jspsych-contrib/extension-device-motion", "version": "1.0.0", @@ -19132,6 +19148,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@jspsych-contrib/extension-countdown": { + "version": "file:packages/extension-countdown", + "requires": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + } + }, "@jspsych-contrib/extension-device-motion": { "version": "file:packages/extension-device-motion", "requires": { diff --git a/packages/extension-countdown/docs/jspsych-countdown.md b/packages/extension-countdown/docs/jspsych-countdown.md new file mode 100644 index 00000000..aa8a32ae --- /dev/null +++ b/packages/extension-countdown/docs/jspsych-countdown.md @@ -0,0 +1,95 @@ +# countdown + +This extension adds a countdown during a trial. + +## Parameters + +### Initialization Parameters + +None + +### Trial Parameters + +Trial parameters can be set when adding the extension to a trial object. + +```javascript +let trial = { + type: jsPsych..., + extensions: [ + {type: jsPsychExtensionWebgazer, params: {...}} + ] +} +``` + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| time | number | undefined | Time in milliseconds of the countdown | +| update_time | number | 50 | How often to update the countdown display; in milliseconds | +| format | function | (time) => String(Math.floor(time / 1000)) | The displayed content of the countdown. Receives the current time left in milliseconds and returns a string for display. | + +## Data Generated + +None + +## Functions + +These functions below are provided to enable a better interaction with the countdown. Note that all of the functions below must be prefixed with `jsPsych.extensions.countdown` (e.g. `jsPsych.extensions.countdown.pause()`). + +### `pause()` + +Pauses the countdown. + +### `resume()` + +Resumes the countdown. + +## Example + +```javascript +let jsPsych = initJsPsych({ + extensions: [{ type: jsPsychExtensionCountdown }], +}); + +let trial = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Hello world", + extensions: [ + { + type: jsPsychExtensionCountdown, + params: { + time: 5000, + update_time: 20, + format: (time) => { + if (time < 3000) { + document.querySelector(".jspsych-extension-countdown").style.color = "red"; + } + + let time_in_seconds = time / 1000; + + let minutes = Math.floor(time_in_seconds / 60); + time_in_seconds -= minutes * 60; + + let seconds = Math.floor(time_in_seconds); + + let format_number = (number) => { + let temp_str = `0${number}`; + return temp_str.substring(temp_str.length - 2); + }; + + return `${format_number(minutes)}:${format_number(seconds)}`; + }, + }, + }, + ], + on_load: function () { + setTimeout(() => { + jsPsych.extensions.countdown.pause(); + setTimeout(() => { + jsPsych.extensions.countdown.resume(); + }, 2000); + }, 1000); + }, +}; + +jsPsych.run([trial]); +``` diff --git a/packages/extension-countdown/examples/example.html b/packages/extension-countdown/examples/example.html new file mode 100644 index 00000000..8f6d3eca --- /dev/null +++ b/packages/extension-countdown/examples/example.html @@ -0,0 +1,64 @@ + + + + + + Countdown Extension Example + + + + + + + + + diff --git a/packages/extension-countdown/jest.config.cjs b/packages/extension-countdown/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/extension-countdown/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/extension-countdown/package.json b/packages/extension-countdown/package.json new file mode 100644 index 00000000..3016adb8 --- /dev/null +++ b/packages/extension-countdown/package.json @@ -0,0 +1,44 @@ +{ + "name": "@jspsych-contrib/extension-countdown", + "version": "0.0.1", + "description": "jsPsych extension for adding a countdown during a trial", + "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/extension-countdown" + }, + "author": "Shaobin Jiang", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jspsych-contrib/issues" + }, + "homepage": "https://github.com/jspsych/jspsych-contrib/tree/main/packages/extension-countdown", + "peerDependencies": { + "jspsych": ">=7.0.0" + }, + "devDependencies": { + "@jspsych/config": "^2.0.0", + "@jspsych/test-utils": "^1.0.0", + "jspsych": "^7.0.0" + } +} diff --git a/packages/extension-countdown/rollup.config.mjs b/packages/extension-countdown/rollup.config.mjs new file mode 100644 index 00000000..3e4c87de --- /dev/null +++ b/packages/extension-countdown/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychExtensionCountdown"); diff --git a/packages/extension-countdown/src/index.spec.ts b/packages/extension-countdown/src/index.spec.ts new file mode 100644 index 00000000..21430f8e --- /dev/null +++ b/packages/extension-countdown/src/index.spec.ts @@ -0,0 +1,21 @@ +import htmlButtonResponse from "@jspsych/plugin-html-button-response"; +import { initJsPsych } from "jspsych"; + +import CountdownExtension from "."; + +describe("Countdown Extension", () => { + it("should pass", () => { + const jsPsych = initJsPsych({ + extensions: [{ type: CountdownExtension }], + }); + + let trial = { + type: htmlButtonResponse, + stimulus: "Hello world", + choices: ["Foo", "Bar"], + extensions: [{ type: CountdownExtension, params: { time: 5000 } }], + }; + + jsPsych.run([trial]); + }); +}); diff --git a/packages/extension-countdown/src/index.ts b/packages/extension-countdown/src/index.ts new file mode 100644 index 00000000..1578938f --- /dev/null +++ b/packages/extension-countdown/src/index.ts @@ -0,0 +1,87 @@ +import { JsPsych, JsPsychExtension, JsPsychExtensionInfo } from "jspsych"; + +interface OnStartParameters { + format: (time: number) => string; + time: number; + update_time: number; +} + +/** + * **Extension-Countdown** + * + * jsPsych extension for adding a countdown for a trial + * + * @author Shaobin Jiang + */ +class CountdownExtension implements JsPsychExtension { + static info: JsPsychExtensionInfo = { + name: "countdown", + }; + + constructor(private jsPsych: JsPsych) {} + + private format: (time: number) => string; + private time: number; + private update_time: number; + + private countdown_element: HTMLElement; + private timer: number; + private last_recorded_time: number; + private time_elapsed: number = 0; + private is_running: boolean = true; + + initialize = (): Promise => { + return new Promise((resolve, _) => { + resolve(); + }); + }; + + on_start = ({ + format = (time: number) => String(Math.floor(time / 1000)), + time, + update_time = 50, + }: OnStartParameters): void => { + this.format = format; + this.time = time; + this.update_time = update_time; + + this.countdown_element = document.createElement("div"); + this.countdown_element.innerHTML = this.format(time); + this.countdown_element.className = "jspsych-extension-countdown"; + this.countdown_element.style.cssText = "font-size: 18px; position: fixed; top: 5%; right: 5%;"; + }; + + on_load = (): void => { + this.jsPsych.getDisplayContainerElement().appendChild(this.countdown_element); + this.last_recorded_time = performance.now(); + this.timer = window.setInterval(() => { + let now: number = performance.now(); + if (this.is_running) { + this.time_elapsed += now - this.last_recorded_time; + } + this.last_recorded_time = now; + let time_left = this.time - this.time_elapsed; + if (time_left <= 0) { + window.clearInterval(this.timer); + } else { + this.countdown_element.innerHTML = this.format(time_left); + } + }, this.update_time); + }; + + on_finish = () => { + window.clearInterval(this.timer); + this.countdown_element.remove(); + return {}; + }; + + pause = (): void => { + this.is_running = false; + }; + + resume = (): void => { + this.is_running = true; + }; +} + +export default CountdownExtension; diff --git a/packages/extension-countdown/tsconfig.json b/packages/extension-countdown/tsconfig.json new file mode 100644 index 00000000..3eabd0c2 --- /dev/null +++ b/packages/extension-countdown/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.contrib.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}