diff --git a/README.md b/README.md index 0ddf4da..0878419 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ To deliver this task offline, open `index.html` in a browser of choice. To deliv To deliver this task offline in a spectrometer, adjust the key layout defined in `index.ts` to map the signals generated by spectrometer controllers. Run `yarn build:spectrometer` to generate output with updated button icons and key layouts to suit spectrometer delivery and controller interaction. This task requires a minimum of four physical buttons for playing the game and a signal signifying spectrometer data collection. +> [!IMPORTANT] +> When using the Current Designs button box, set the following *Output modes* for the corresponding task version: +> +> * **v1.8.2 and higher**: Set *Output mode* to `HID_NAR_12345` +> * **v1.8.1 and earlier**: Set *Output mode* to `HID_KEY_12345` + ## License diff --git a/analysis/rdk_data_dictionary_v1.7.1.md b/analysis/v1.7.1_data_dictionary.md similarity index 100% rename from analysis/rdk_data_dictionary_v1.7.1.md rename to analysis/v1.7.1_data_dictionary.md diff --git a/analysis/v1.8.2_data_dictionary.md b/analysis/v1.8.2_data_dictionary.md new file mode 100644 index 0000000..9c9109d --- /dev/null +++ b/analysis/v1.8.2_data_dictionary.md @@ -0,0 +1,47 @@ +# RDK Task - Data Dictionary + +> Version 1.8.2 + +Release generated October 16, 2025. + +Browse files: [https://github.com/Brain-Development-and-Disorders-Lab/task_rdk/tree/v1.8.2](https://github.com/Brain-Development-and-Disorders-Lab/task_rdk/tree/v1.8.2) + +## Data + +### General + +- `trialNumber`: Number of elapsed trials, indexed from `0`. +- `score`: Total number of correct responses, counting `dot-game`-type `main` trials only (not calibration or practice trials). +- `deviation`: The direction of the coherent dots, either `left` or `right`. +- `correct`: Whether the participant selected the correct direction of the coherent dots, either `1` or `0`. +- `coherence`: The current proportion of coherent dots being presented during the moving dots stimulus. Example: A value of `0.13` indicates 13% of dots on-screen will be coherent, the remaining 87% will be random distractor dots. +- `coherences`: Generated low and high coherence pairs, `[low, high]`. Calculated as `[kMedian * 0.5, kMedian * 2.0]`, where `kMedian <= 0.5 && kMedian >= 0.12`. + +### Decisions + +- `selection`: The selected button value, `vc_l`, `sc_l`, `sc_r`, or `vc_r`. + +### Timing + +- `trialStartTime`: Timestamp of when the start of the trial is invoked. +- `trialEndTime`: Timestamp of when the trial is ended and the next trial invoked. +- `trialTotalTime`: Total duration of the trial presentation. +- `referenceStartTime`: Timestamp of when the start of the dot direction decision is presented. +- `referenceEndTime`: Timestamp of when the direction is selected and the selection is stored. +- `referenceTotalTime`: Total duration of the direction decision being presented and the participant making a selection. +- `confidenceStartTime`: Timestamp of when the start of the confidence slider is presented. +- `confidenceEndTime`: Timestamp of when the confidence value has been selected and the trial continues. +- `confidenceTotalTime`: Total duration of the confidence slider being presented and the participant making a slider selection. +- `stimulusDuration`: Total duration that the moving dot stimulus is presented for (default is `1500` milliseconds). + +## Trial Structure + +1. `initial`: Presenting a fixation cross for `1000` milliseconds. +2. `motion`: Presenting the dot motion for `trial.stimulusDuration` milliseconds (default `1500`). +3. `reference`: Presenting the dot direction decision, no time limit. +4. `decision`: Presenting a fixation cross for `250` milliseconds. +5. `confidence`: Presenting the confidence selection slider **if enabled on that trial**, no time limit. + +## Notes + +- Due to inaccuracies when using millisecond-precision timing alongside complex 2D rendering on the web, the result for `trialTotalTime` is typically between `65`-`75` milliseconds **greater** than the sum of other `-TotalTime` values and fixed durations. diff --git a/package.json b/package.json index ea6e798..dd4b6ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "task_rdk", - "version": "1.8.1", + "version": "1.8.2", "description": "RDK Task", "scripts": { "build": "yarn build:desktop", diff --git a/src/classes/Graphics.ts b/src/classes/Graphics.ts index 2f3b829..56106db 100644 --- a/src/classes/Graphics.ts +++ b/src/classes/Graphics.ts @@ -103,7 +103,7 @@ export class Graphics { ); if ( this.trial.showFeedback === true && - this.trial.data.referenceSelection !== "" + this.trial.data.selection !== "" ) { if (this.trial.data.correct === 1) { this.renderer.createFixation(0, 0, fixationDiameter, false, Renderer.getInvertedColor("green")); @@ -150,7 +150,7 @@ export class Graphics { const startAngle = reference - Math.PI / 4; const endAngle = startAngle + Math.PI / 4; this.renderer.createArc(startAngle - Math.PI / 128, endAngle, Renderer.getInvertedColor("white")); - this.renderer.createArc(startAngle, endAngle, Renderer.getInvertedColor("#3ea3a3")); + this.renderer.createArc(startAngle, endAngle, Renderer.getInvertedColor("#1792fc")); } /** @@ -159,7 +159,7 @@ export class Graphics { addRightArc(): void { const startAngle = -Math.PI / 2; const endAngle = Math.PI / 2; - this.renderer.createArc(startAngle, endAngle, Renderer.getInvertedColor("#3ea3a3")); + this.renderer.createArc(startAngle, endAngle, Renderer.getInvertedColor("#1792fc")); } /** @@ -256,113 +256,6 @@ export class Graphics { } } - /** - * Setup HTML elements for confidence selection element - * @param parameters configuration information for confidence selection - */ - addConfidence(parameters: any): void { - // Confidence instructions - let html = ""; - - // Controls for confidence slider and submission - if (__TARGET__ === "spectrometer") { - html += `
`; - // Decrease confidence - html += `
`; - html += Renderer.getEmbeddedControllerButton(1); - html += `

Decrease Confidence

`; - html += `
`; - - // Submit - html += `
`; - html += `

Continue

`; - html += Renderer.getEmbeddedControllerButton(3); - html += `
`; - - // Increase confidence - html += `
`; - html += `

Increase Confidence

`; - html += Renderer.getEmbeddedControllerButton(4); - html += `
`; - html += `
`; - } else { - html += `
`; - // Decrease confidence - html += `
`; - html += Renderer.getEmbeddedKeyboardButton("F"); - html += `

Decrease Confidence

`; - html += `
`; - - // Submit - html += `
`; - html += `

Continue

`; - html += Renderer.getEmbeddedKeyboardButton("K"); - html += `
`; - - // Increase confidence - html += `
`; - html += `

Increase Confidence

`; - html += Renderer.getEmbeddedKeyboardButton("J"); - html += `
`; - html += `
`; - } - - // Confidence slider - html += `
`; - html += `
`; - html += ``; - html += ``; - - // Add labels - const labels = ["50%", "60%", "70%", "80%", "90%", "100%"]; - html += `
`; - for (let i = 0; i < labels.length; i++) { - const width = 100 / (labels.length - 1); - const offset = i * width - width / 2; - html += `
`; - html += `${labels[i]}`; - html += `
`; - } - html += `
`; - html += `
`; - html += `
`; - html += `
`; - - // Button to notify of a mistake - html += `
`; - if (__TARGET__ === "spectrometer") { - html += `

`; - html += `I made a mistake`; - html += `

`; - html += Renderer.getEmbeddedControllerButton(2); - } else { - html += ``; - html += Renderer.getEmbeddedKeyboardButton("D"); - } - html += `
`; - - // Add the HTML to the display element - this.renderer.getDisplayElement().parentNode.innerHTML = html; - - // Try to hide the thumb - document - .getElementById("confidence-slider") - .addEventListener("click", () => { - const slider = document.getElementById("confidence-slider"); - slider.className = "confidence-slider"; - }); - - // Bind appropriate event listeners to actions - document.addEventListener("keyup", parameters.eventHandler); - if (__TARGET__ === "desktop") { - document - .getElementById("mistake-button") - .addEventListener("click", parameters.eventHandler); - } - } - /** * Clear all elements from the renderer * This removes everything including aperture outline and fixation cross @@ -386,5 +279,8 @@ export class Graphics { for (let i = 0; i < trackedElements.length; i++) { this.renderer.getTarget().remove(trackedElements[i]); } + + // Clear decision containers if they exist + this.renderer.clearDecisionContainers(); } } diff --git a/src/classes/Renderer.ts b/src/classes/Renderer.ts index 6fbf60e..f76c479 100644 --- a/src/classes/Renderer.ts +++ b/src/classes/Renderer.ts @@ -256,29 +256,91 @@ export class Renderer { } } - addButton(buttonText: string, offset = "none"): HTMLDivElement { + addButton(buttonText: string, variant = "default"): HTMLDivElement { + // Create colorscheme based on variant + const colorscheme = { + default: { + background: "#88919e", + fill: "#575d66", + }, + orangeDark: { + background: "#ffc20a", + fill: "#ffdb6e", + }, + orangeLight: { + background: "#ffd75e", + fill: "#fce7a9", + }, + blueDark: { + background: "#1792fc", + fill: "#47a8fc", + }, + blueLight: { + background: "#7bbef8", + fill: "#9dd0fc", + }, + }; + // Create container const buttonContainer = document.createElement("div"); buttonContainer.style.display = "flex"; buttonContainer.style.justifyContent = "center"; buttonContainer.style.alignItems = "center"; - buttonContainer.style.width = "100px"; - buttonContainer.style.height = "50px"; - buttonContainer.style.padding = "2px"; - buttonContainer.style.border = "4px solid " + Renderer.getInvertedColor("black"); - buttonContainer.style.borderRadius = "12px"; - buttonContainer.style.backgroundColor = Renderer.getInvertedColor("white"); + buttonContainer.style.width = "140px"; + buttonContainer.style.height = "60px"; + buttonContainer.style.padding = "4px"; + buttonContainer.style.borderRadius = "8px"; + buttonContainer.style.backgroundColor = colorscheme[variant].background; + buttonContainer.style.position = "relative"; + buttonContainer.style.overflow = "hidden"; + + // Create progress bar + const progressBar = document.createElement("div"); + progressBar.style.position = "absolute"; + progressBar.style.bottom = "0"; + progressBar.style.left = "0"; + progressBar.style.width = "0%"; + progressBar.style.height = "100%"; + progressBar.style.backgroundColor = colorscheme[variant].fill; + progressBar.style.transition = "width 2s linear"; + progressBar.className = "progress-bar"; + buttonContainer.appendChild(progressBar); // Add label text for the button const buttonLabel = document.createElement("p"); buttonLabel.textContent = buttonText; buttonLabel.style.fontWeight = "bold"; - buttonLabel.style.fontSize = "xx-large"; + buttonLabel.style.fontSize = "x-large"; + buttonLabel.style.position = "relative"; + buttonLabel.style.zIndex = "1"; + buttonLabel.style.color = "black"; buttonContainer.appendChild(buttonLabel); return buttonContainer; } + /** + * Start progress animation for a button + * @param {HTMLDivElement} buttonElement the button element to animate + */ + startProgress(buttonElement: HTMLDivElement): void { + const progressBar = buttonElement.querySelector('.progress-bar') as HTMLElement; + if (progressBar) { + progressBar.style.width = "100%"; + } + } + + /** + * Stop progress animation for a button + * @param {HTMLDivElement} buttonElement the button element to reset + */ + stopProgress(buttonElement: HTMLDivElement): void { + const progressBar = buttonElement.querySelector('.progress-bar') as HTMLElement; + if (progressBar) { + progressBar.style.width = "0%"; + } + } + /** * Add a label onto the target * @param {string} labelType the type of label to add @@ -289,29 +351,69 @@ export class Renderer { const graphicsCanvasDiv = document.getElementsByClassName("graphics-container")[0]; - // Create the left label container + // Create the left container const leftContainer = document.createElement("div"); + leftContainer.id = "left-container"; leftContainer.style.display = "flex"; - leftContainer.style.justifyContent = "center"; - leftContainer.style.alignItems = "center"; leftContainer.style.flexDirection = "column"; - leftContainer.style.gap = "10px"; + leftContainer.style.alignItems = "center"; + leftContainer.style.justifyContent = "center"; leftContainer.style.position = "absolute"; - leftContainer.style.marginRight = "60%"; - leftContainer.style.marginTop = "40px"; + leftContainer.style.marginRight = "70%"; - // Add the left button to the left label container - const leftButton = this.addButton("Left", "left"); - leftContainer.append(leftButton); + // Left label + const leftLabel = document.createElement("p"); + leftLabel.textContent = "Left"; + leftLabel.style.fontSize = "xx-large"; + leftLabel.style.fontWeight = "bold"; + leftContainer.append(leftLabel); - // Add the input indicators + // Create the left button container const leftButtonContainer = document.createElement("div"); + leftButtonContainer.style.display = "flex"; + leftButtonContainer.style.justifyContent = "center"; + leftButtonContainer.style.alignItems = "center"; + leftButtonContainer.style.flexDirection = "row"; + leftButtonContainer.style.gap = "16px"; + leftContainer.append(leftButtonContainer); + + // Left "Very Confident" button + const leftVeryConfidentButtonContainer = document.createElement("div"); + leftVeryConfidentButtonContainer.style.display = "flex"; + leftVeryConfidentButtonContainer.style.justifyContent = "center"; + leftVeryConfidentButtonContainer.style.alignItems = "center"; + leftVeryConfidentButtonContainer.style.flexDirection = "column"; + leftVeryConfidentButtonContainer.style.gap = "16px"; + const leftVeryConfidentButton = this.addButton("Very Confident", "orangeDark"); + leftVeryConfidentButton.setAttribute("data-button-id", "vc_l"); + leftVeryConfidentButtonContainer.append(leftVeryConfidentButton); + const leftVeryConfidentButtonLabelContainer = document.createElement("div"); if (__TARGET__ === "spectrometer") { - leftButtonContainer.innerHTML = Renderer.getEmbeddedControllerButton(1); + leftVeryConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedControllerButton(1); } else { - leftButtonContainer.innerHTML = Renderer.getEmbeddedKeyboardButton("F"); + leftVeryConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedKeyboardButton("D"); } - leftContainer.append(leftButtonContainer); + leftVeryConfidentButtonContainer.append(leftVeryConfidentButtonLabelContainer); + leftButtonContainer.append(leftVeryConfidentButtonContainer); + + // Left "Somewhat Confident" button + const leftSomewhatConfidentButtonContainer = document.createElement("div"); + leftSomewhatConfidentButtonContainer.style.display = "flex"; + leftSomewhatConfidentButtonContainer.style.justifyContent = "center"; + leftSomewhatConfidentButtonContainer.style.alignItems = "center"; + leftSomewhatConfidentButtonContainer.style.flexDirection = "column"; + leftSomewhatConfidentButtonContainer.style.gap = "16px"; + const leftSomewhatConfidentButton = this.addButton("Somewhat Confident", "orangeLight"); + leftSomewhatConfidentButton.setAttribute("data-button-id", "sc_l"); + leftSomewhatConfidentButtonContainer.append(leftSomewhatConfidentButton); + const leftSomewhatConfidentButtonLabelContainer = document.createElement("div"); + if (__TARGET__ === "spectrometer") { + leftSomewhatConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedControllerButton(2); + } else { + leftSomewhatConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedKeyboardButton("F"); + } + leftSomewhatConfidentButtonContainer.append(leftSomewhatConfidentButtonLabelContainer); + leftButtonContainer.append(leftSomewhatConfidentButtonContainer); // Prepend the left label container to the graphics container graphicsCanvasDiv.prepend(leftContainer); @@ -320,29 +422,69 @@ export class Renderer { const graphicsCanvasDiv = document.getElementsByClassName("graphics-container")[0]; - // Create the right label container + // Create the right container const rightContainer = document.createElement("div"); + rightContainer.id = "right-container"; rightContainer.style.display = "flex"; - rightContainer.style.justifyContent = "center"; - rightContainer.style.alignItems = "center"; rightContainer.style.flexDirection = "column"; - rightContainer.style.gap = "10px"; + rightContainer.style.alignItems = "center"; + rightContainer.style.justifyContent = "center"; rightContainer.style.position = "absolute"; - rightContainer.style.marginLeft = "60%"; - rightContainer.style.marginTop = "40px"; + rightContainer.style.marginLeft = "70%"; - // Add the right button to the right label container - const rightButton = this.addButton("Right", "right"); - rightContainer.append(rightButton); + // Right label + const rightLabel = document.createElement("p"); + rightLabel.textContent = "Right"; + rightLabel.style.fontSize = "xx-large"; + rightLabel.style.fontWeight = "bold"; + rightContainer.append(rightLabel); - // Add the input indicators + // Create the right button container const rightButtonContainer = document.createElement("div"); + rightButtonContainer.style.display = "flex"; + rightButtonContainer.style.justifyContent = "center"; + rightButtonContainer.style.alignItems = "center"; + rightButtonContainer.style.flexDirection = "row"; + rightButtonContainer.style.gap = "16px"; + rightContainer.append(rightButtonContainer); + + // Right "Somewhat Confident" button + const rightSomewhatConfidentButtonContainer = document.createElement("div"); + rightSomewhatConfidentButtonContainer.style.display = "flex"; + rightSomewhatConfidentButtonContainer.style.justifyContent = "center"; + rightSomewhatConfidentButtonContainer.style.alignItems = "center"; + rightSomewhatConfidentButtonContainer.style.flexDirection = "column"; + rightSomewhatConfidentButtonContainer.style.gap = "16px"; + const rightSomewhatConfidentButton = this.addButton("Somewhat Confident", "blueLight"); + rightSomewhatConfidentButton.setAttribute("data-button-id", "sc_r"); + rightSomewhatConfidentButtonContainer.append(rightSomewhatConfidentButton); + const rightSomewhatConfidentButtonLabelContainer = document.createElement("div"); if (__TARGET__ === "spectrometer") { - rightButtonContainer.innerHTML = Renderer.getEmbeddedControllerButton(4); + rightSomewhatConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedControllerButton(3); } else { - rightButtonContainer.innerHTML = Renderer.getEmbeddedKeyboardButton("J"); + rightSomewhatConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedKeyboardButton("J"); } - rightContainer.append(rightButtonContainer); + rightSomewhatConfidentButtonContainer.append(rightSomewhatConfidentButtonLabelContainer); + rightButtonContainer.append(rightSomewhatConfidentButtonContainer); + + // Right "Very Confident" button + const rightVeryConfidentButtonContainer = document.createElement("div"); + rightVeryConfidentButtonContainer.style.display = "flex"; + rightVeryConfidentButtonContainer.style.justifyContent = "center"; + rightVeryConfidentButtonContainer.style.alignItems = "center"; + rightVeryConfidentButtonContainer.style.flexDirection = "column"; + rightVeryConfidentButtonContainer.style.gap = "16px"; + const rightVeryConfidentButton = this.addButton("Very Confident", "blueDark"); + rightVeryConfidentButton.setAttribute("data-button-id", "vc_r"); + rightVeryConfidentButtonContainer.append(rightVeryConfidentButton); + const rightVeryConfidentButtonLabelContainer = document.createElement("div"); + if (__TARGET__ === "spectrometer") { + rightVeryConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedControllerButton(4); + } else { + rightVeryConfidentButtonLabelContainer.innerHTML = Renderer.getEmbeddedKeyboardButton("K"); + } + rightVeryConfidentButtonContainer.append(rightVeryConfidentButtonLabelContainer); + rightButtonContainer.append(rightVeryConfidentButtonContainer); // Append the right label container to the graphics container graphicsCanvasDiv.append(rightContainer); @@ -364,13 +506,13 @@ export class Renderer { // Create SVG with 4 circles representing the controller buttons const svg = ` - - + ${[1, 2, 3, 4].map((index) => { - const centerX = 16 * index; - const centerY = 20; + const centerX = 18 * index; + const centerY = 22.5; const radius = 6; const isHighlighted = index === validIndex; @@ -477,7 +619,7 @@ export class Renderer { /** * Clear all HTML elements from the display - * This removes HTML elements like confidence sliders, labels, etc. + * This removes HTML elements like labels, etc. */ clearHTMLElements(): void { const graphicsContainer = document.getElementsByClassName("graphics-container")[0]; @@ -494,6 +636,22 @@ export class Renderer { } } + /** + * Clear the left and right decision containers + * This removes only the decision UI elements without affecting other HTML elements + */ + clearDecisionContainers(): void { + const leftContainer = document.getElementById("left-container"); + if (leftContainer) { + leftContainer.remove(); + } + + const rightContainer = document.getElementById("right-container"); + if (rightContainer) { + rightContainer.remove(); + } + } + /** * Get the width of the view * @return {number} the width of the view diff --git a/src/classes/Stimulus.ts b/src/classes/Stimulus.ts index c4f2f76..e8017d0 100644 --- a/src/classes/Stimulus.ts +++ b/src/classes/Stimulus.ts @@ -56,12 +56,21 @@ export class Stimulus { createKeybindings(): void { // Check if the stimulus can be interacted with if (this.interactive) { - // Bind keys + // Bind keydown events for (const binding in this.keybindings) { if (this.keybindings[binding]) { - document.addEventListener("keyup", this.keybindings[binding].handler); + document.addEventListener("keydown", this.keybindings[binding].handler); } } + + // Also bind keyup events for decision stimulus + if (this.name === "decision") { + document.addEventListener("keyup", (event: KeyboardEvent) => { + if (window.decisionKeyUpHandler) { + window.decisionKeyUpHandler(event); + } + }); + } } } @@ -69,15 +78,26 @@ export class Stimulus { * Remove keybindings for the stimulus */ removeKeybindings(): void { - // Unbind keys + // Unbind keydown events for (const binding in this.keybindings) { if (this.keybindings[binding]) { document.removeEventListener( - "keyup", + "keydown", this.keybindings[binding].handler ); } } + + // Also remove keyup events for decision stimulus + if (this.name === "decision") { + document.removeEventListener("keyup", window.decisionKeyUpHandler); + } + + // Remove post-decision keyup handler if it exists + if (this.name === "post-decision" && window.postDecisionKeyUpHandler) { + document.removeEventListener("keyup", window.postDecisionKeyUpHandler); + window.postDecisionKeyUpHandler = undefined; + } } /** @@ -150,8 +170,6 @@ export class Stimulus { graphics.addRightArc(); } else if (component === "indicator") { graphics.addReferenceIndicator(); - } else if (component === "confidence") { - graphics.addConfidence(parameters); } else if (component === "left") { graphics.addLeftLabel(); } else if (component === "right") { diff --git a/src/css/styles.css b/src/css/styles.css index db470da..e44af3a 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -1,7 +1,5 @@ /* -Stylesheet for Bang et al. RDK task. Primary -style features relate to slider input used for -confidence estimate. +Stylesheet for Bang et al. RDK task. Author: Henry Burgess */ @@ -34,69 +32,6 @@ Author: Henry Burgess margin: 5%; } -.confidence-slider { - -webkit-appearance: none; - margin-top: 10px; - width: 100%; - height: 15px; - border-radius: 5px; - background: #000000; - outline: none; - opacity: 0.7; - -webkit-transition: 0.2s; - transition: opacity 0.2s; -} - -.confidence-slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 1.5rem; - height: 64px; - border-radius: 10px; - background: #ff70ff; - cursor: pointer; -} - -.confidence-slider::-moz-range-thumb { - width: 1.5rem; - height: 64px; - border-radius: 10px; - background: ff70ff; - cursor: pointer; -} - -.confidence-slider-hidden { - -webkit-appearance: none; - width: 100%; - height: 15px; - border-radius: 5px; - background: #000000; - outline: none; - opacity: 0.7; - -webkit-transition: 0.2s; - transition: opacity 0.2s; -} - -.confidence-slider-hidden::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 1.5rem; - height: 64px; - border-radius: 10px; - background: ff70ff; - cursor: pointer; - visibility: hidden; -} - -.confidence-slider-hidden::-moz-range-thumb { - width: 1.5rem; - height: 64px; - border-radius: 10px; - background: ff70ff; - cursor: pointer; - visibility: hidden; -} - .reference-instructions-container { padding: 0; } @@ -112,38 +47,6 @@ Author: Henry Burgess text-align: center; } -#confidence-controls-container { - display: flex; - flex-direction: row; - width: 90vw; - justify-content: space-between; - align-items: center; -} - -#confidence-controls-container-element { - display: flex; - flex-direction: row; - gap: 10px; - justify-content: center; - align-items: center; - width: 100%; -} - -#mistake-button-container { - display: flex; - flex-direction: row; - gap: 10px; - margin-top: 24px; - justify-content: center; - align-items: center; - width: 100%; -} - -#mistake-button-container button { - font-size: 18px; - font-weight: bold; -} - /* Base control button class */ .control-button { font-family: Arial, Helvetica, sans-serif; @@ -200,14 +103,6 @@ body.inverted { color: white; } -.graphics-container.inverted .confidence-slider { - background: #ffffff; -} - -.graphics-container.inverted .confidence-slider-hidden { - background: #ffffff; -} - /* Invert label borders */ body.inverted .addButton { border-color: white !important; diff --git a/src/index.ts b/src/index.ts index 875e6e9..2308007 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,18 +66,18 @@ const jsPsych = initJsPsych({ const InputConfigurations = { desktop: { name: "desktop", - left: "f", - right: "j", - submit: "k", - alt: "d", + 1: "d", + 2: "f", + 3: "j", + 4: "k", showButtons: false, }, spectrometer: { name: "spectrometer", - left: "4", - right: "1", - submit: "2", - alt: "3", + 1: "1", + 2: "2", + 3: "3", + 4: "4", trigger: "5", showButtons: false, }, @@ -87,21 +87,21 @@ const InputConfigurations = { export const Manipulations = { numTutorialTrials: jsPsych.extensions.Neurocog.getManipulation( "numTutorialTrials", - 10 + 8 ), numPracticeTrials: jsPsych.extensions.Neurocog.getManipulation( "numPracticeTrials", - 10 + 8 ), numCalibrationOneTrials: jsPsych.extensions.Neurocog.getManipulation( "numCalibrationOneTrials", - 120 + 36 ), numMainTrials: jsPsych.extensions.Neurocog.getManipulation( "numMainTrials", - 200 + 40 ), - invertColors: jsPsych.extensions.Neurocog.getManipulation("invertColors", false), + invertColors: __TARGET__ === "spectrometer", requireID: jsPsych.extensions.Neurocog.getManipulation("requireID", false), enableFullscreen: jsPsych.extensions.Neurocog.getManipulation("enableFullscreen", false), showInstructions: jsPsych.extensions.Neurocog.getManipulation( @@ -177,8 +177,8 @@ if ( instructionContinueText, ], allow_keys: !keyLayout.showButtons, - key_forward: keyLayout.right.charAt(keyLayout.right.length - 1), - key_backward: keyLayout.left.charAt(keyLayout.left.length - 1), + key_forward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["4"] : keyLayout["3"], + key_backward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["1"] : keyLayout["2"], show_page_number: true, show_clickable_nav: keyLayout.showButtons, }); @@ -198,8 +198,8 @@ timeline.push({ type: InstructionsPlugin, pages: tutorialGames, allow_keys: !keyLayout.showButtons, - key_forward: keyLayout.right.charAt(keyLayout.right.length - 1), - key_backward: keyLayout.left.charAt(keyLayout.left.length - 1), + key_forward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["4"] : keyLayout["3"], + key_backward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["1"] : keyLayout["2"], show_page_number: true, show_clickable_nav: keyLayout.showButtons, }); @@ -226,7 +226,6 @@ for (let t = 0; t < Manipulations.numTutorialTrials; t++) { dotDirection: r, dotVelocity: 2.0, showFeedback: false, - checkConfidence: false, keyLayout: keyLayout, data: { name: trialName, @@ -234,10 +233,9 @@ for (let t = 0; t < Manipulations.numTutorialTrials; t++) { coherence: k, stimulusDuration: d, dotDirection: r, - referenceSelection: "", + selection: "", deviation: "left", correct: false, - confidenceSelection: 0, }, extensions: [{ type: NeurocogExtension }], }; @@ -251,8 +249,7 @@ const practice = [ `

Practice Games

` + `

You will now play another ${Manipulations.numPracticeTrials} ` + `practice games. ` + - `You won't have to rate your confidence after each game, ` + - `but you will be shown if your answer was correct or not.

` + + `You will be shown if your answer was correct or not.

` + `

If your answer was correct, the cross in the ` + `middle of the screen will briefly turn green.

` + `

If your answer was wrong, the cross in the ` + @@ -264,8 +261,8 @@ timeline.push({ type: InstructionsPlugin, pages: practice, allow_keys: !keyLayout.showButtons, - key_forward: keyLayout.right.charAt(keyLayout.right.length - 1), - key_backward: keyLayout.left.charAt(keyLayout.left.length - 1), + key_forward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["4"] : keyLayout["3"], + key_backward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["1"] : keyLayout["2"], show_page_number: true, show_clickable_nav: keyLayout.showButtons, }); @@ -289,7 +286,6 @@ for (let t = 0; t < Manipulations.numPracticeTrials; t++) { dotDirection: r, dotVelocity: 2.0, showFeedback: true, - checkConfidence: false, keyLayout: keyLayout, data: { name: trialName, @@ -297,10 +293,9 @@ for (let t = 0; t < Manipulations.numPracticeTrials; t++) { coherence: k, stimulusDuration: 1500, dotDirection: r, - referenceSelection: "", + selection: "", deviation: deviation, correct: false, - confidenceSelection: 0, }, extensions: [{ type: NeurocogExtension }], }; @@ -321,9 +316,6 @@ if (Manipulations.numCalibrationOneTrials + Manipulations.numMainTrials > 0) { Manipulations.numCalibrationOneTrials + Manipulations.numMainTrials } ` + `games.

` + - `

You will not be shown if you have correctly ` + - `answered or not, and you will be asked to rate your ` + - `confidence after some of the games.

` + `
` + `

Good luck!

` + instructionContinueText @@ -358,7 +350,7 @@ if (_.isEqual(keyLayout.name, "spectrometer")) { type: InstructionsPlugin, pages: spectrometer, allow_keys: !keyLayout.showButtons, - key_forward: keyLayout.trigger.charAt(keyLayout.trigger.length - 1), + key_forward: keyLayout.trigger, show_clickable_nav: keyLayout.showButtons, }); } else { @@ -366,8 +358,8 @@ if (_.isEqual(keyLayout.name, "spectrometer")) { type: InstructionsPlugin, pages: main, allow_keys: !keyLayout.showButtons, - key_forward: keyLayout.right.charAt(keyLayout.right.length - 1), - key_backward: keyLayout.left.charAt(keyLayout.left.length - 1), + key_forward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["4"] : keyLayout["3"], + key_backward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["1"] : keyLayout["2"], show_page_number: true, show_clickable_nav: keyLayout.showButtons, }); @@ -392,7 +384,6 @@ for (let t = 0; t < Manipulations.numCalibrationOneTrials; t++) { dotDirection: r, dotVelocity: 2.0, showFeedback: false, - checkConfidence: true, keyLayout: keyLayout, data: { name: trialName, @@ -400,10 +391,9 @@ for (let t = 0; t < Manipulations.numCalibrationOneTrials; t++) { coherence: k, stimulusDuration: 1500, dotDirection: r, - referenceSelection: "", + selection: "", deviation: deviation, correct: false, - confidenceSelection: 0, }, extensions: [{ type: NeurocogExtension }], }; @@ -431,7 +421,6 @@ for (let t = 0; t < Manipulations.numMainTrials; t++) { dotDirection: r, dotVelocity: 2.0, showFeedback: false, - checkConfidence:true, keyLayout: keyLayout, data: { name: trialName, @@ -439,10 +428,9 @@ for (let t = 0; t < Manipulations.numMainTrials; t++) { coherence: k, stimulusDuration: 1500, dotDirection: r, - referenceSelection: "", + selection: "", deviation: deviation, correct: false, - confidenceSelection: 0, }, extensions: [{ type: NeurocogExtension }], }; @@ -462,7 +450,7 @@ timeline.push({ allow_backward: false, button_label_next: "Finish", show_clickable_nav: false, - key_forward: keyLayout.right, + key_forward: _.isEqual(keyLayout.name, "spectrometer") ? keyLayout["4"] : keyLayout["3"], }); jsPsych.run(timeline); diff --git a/src/plugin.ts b/src/plugin.ts index 49b10aa..6287658 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -25,6 +25,14 @@ import { IRenderer } from "../types"; import Two from "two.js"; import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; +// Global keyup handler for decision input +declare global { + interface Window { + decisionKeyUpHandler?: (event: KeyboardEvent) => void; + postDecisionKeyUpHandler?: (event: KeyboardEvent) => void; + } +} + const info = { name: "dot-game", parameters: { @@ -61,10 +69,6 @@ const info = { type: ParameterType.INT, default: undefined, }, - checkConfidence: { - type: ParameterType.BOOL, - default: false, - }, }, }; @@ -172,18 +176,12 @@ class DotGamePlugin implements JsPsychPlugin { currentStimulus = stimuli.shift(); // Configure stimulus-specific parameters - if (currentStimulus.getParameters().name === "reference") { + if (currentStimulus.getParameters().name === "decision") { // Reference stimulus trial.data.referenceStartTime = performance.now(); // Hide the mouse cursor graphics.cursorVisibility(false); - } else if (currentStimulus.getParameters().name === "confidence") { - // Start a timer if a confidence stimuli is run. - trial.data.confidenceStartTime = performance.now(); - - // Show the mouse cursor - graphics.cursorVisibility(true); } else if (currentStimulus.getParameters().name === "motion") { trial.data.stimulusDuration = currentStimulus.getParameters().timing.run; @@ -193,12 +191,11 @@ class DotGamePlugin implements JsPsychPlugin { } else if (currentStimulus.getParameters().name === "initial") { // Hide the mouse cursor graphics.cursorVisibility(false); - } else if (currentStimulus.getParameters().name === "decision") { - // Increase the run time of fixation cross to 1250ms if feedback - // is to be shown - if (trial.showFeedback === true) { - currentStimulus.getParameters().timing.run = 1250; - } + } else if (currentStimulus.getParameters().name === "post-decision") { + // Set up keyup handler to wait for all keys to be released + postDecisionKeyUpHandler = createPostDecisionKeyUpHandler(); + window.postDecisionKeyUpHandler = postDecisionKeyUpHandler; + document.addEventListener("keyup", postDecisionKeyUpHandler); } else { // Show the mouse cursor graphics.cursorVisibility(true); @@ -208,105 +205,178 @@ class DotGamePlugin implements JsPsychPlugin { Runner.start(currentStimulus); }; + // Add variables for key hold functionality + let keyHoldTimer: number | null = null; + let currentKey: string | null = null; + let postDecisionKeyUpHandler: ((event: KeyboardEvent) => void) | null = null; + /** - * An event handler for decision made during a trial - * @param {KeyboardEvent} event the particular event or keypress + * An event handler for keydown during decision input */ - const decisionHandler = (event: KeyboardEvent) => { - // Record the keycode to process the event - const keycode = event.key; + const decisionKeyDownHandler = (event: KeyboardEvent) => { + const keycode = event.key.toLowerCase(); // Convert to lowercase for consistent comparison - if ( - Object.keys(currentStimulus.getParameters().keybindings).includes( - keycode - ) - ) { - // Handle the decision if a valid key has been pressed for this stage - if (currentStimulus.getParameters().name === "confidence") { - // Handle 'confidence' stimuli - const slider = document.getElementById( - "confidence-slider" - ) as HTMLInputElement; - if (slider) { - // Show the thumb if it is currently hidden when adjusting confidence - if ( - slider.className === "confidence-slider-hidden" && - (keycode === keyLayout.left || keycode === keyLayout.right) - ) { - slider.className = "confidence-slider"; - } + // Filter out invalid keycodes + if (!Object.keys(currentStimulus.getParameters().keybindings).includes(keycode)) { + return; + } - // Handle incrementing and decrementing slider - if (keycode === keyLayout.left) { - slider.stepDown(1); - } else if (keycode === keyLayout.right) { - slider.stepUp(1); - } + // Prevent default to avoid key repeat + event.preventDefault(); + + // If already holding a key, ignore (prevents multiple simultaneous key holds) + if (currentKey !== null) { + return; + } - // Ensure we handle a mistake notification - if (keycode === keyLayout.alt || event.type === "click") { - // Store mistake boolean - trial.data.confidenceMistake = true; + currentKey = keycode; + + // Find the corresponding button element + const buttonMapping = { + [keyLayout["1"]]: "vc_l", + [keyLayout["2"]]: "sc_l", + [keyLayout["3"]]: "sc_r", + [keyLayout["4"]]: "vc_r" + }; + + const buttonId = buttonMapping[keycode]; + if (buttonId) { + const buttonElement = document.querySelector(`[data-button-id="${buttonId}"]`) as HTMLDivElement; + if (buttonElement) { + // Start the progress bar animation + renderer.startProgress(buttonElement); + } + } - // Calculate and store confidence data - trial.data.confidenceEndTime = performance.now(); - trial.data.confidenceTotalTime = - trial.data.confidenceEndTime - trial.data.confidenceStartTime; - trial.data.confidenceSelection = slider.value; + // Set timer for 2 seconds + keyHoldTimer = window.setTimeout(() => { + if (currentKey === keycode) { + // After 2 seconds, process the decision + decisionHandler(event); + resetKeyHold(); + } + }, 2000); + }; - // Continue to the next Stimulus - console.warn("Mistake stored in trial data"); - Runner.post(currentStimulus); - } + /** + * An event handler for keyup during decision input + */ + const decisionKeyUpHandler = (event: KeyboardEvent) => { + if (currentKey === event.key.toLowerCase()) { + resetKeyHold(); + } + }; - // Finally, we ignore any submissions if the slider is hidden, only submit if slider is visible - if ( - keycode === keyLayout.submit && - slider.className === "confidence-slider" - ) { - // Calculate and store confidence data - trial.data.confidenceEndTime = performance.now(); - trial.data.confidenceTotalTime = - trial.data.confidenceEndTime - trial.data.confidenceStartTime; - trial.data.confidenceSelection = slider.value; - - // Continue to the next Stimulus - Runner.post(currentStimulus); - } - } else { - console.warn("Did not get reference to slider element"); - } - } else if (currentStimulus.getParameters().name === "reference") { - // Handle 'reference' stimuli - selection = - currentStimulus.getParameters().keybindings[keycode].choice; - currentStimulus.removeKeybindings(); - - // Calculate and store reference data - trial.data.referenceEndTime = performance.now(); - trial.data.referenceTotalTime = - trial.data.referenceEndTime - trial.data.referenceStartTime; - - // Normalize selection data - trial.data.referenceSelection = selection === "left" ? 1 : 2; - - // Normalize correct data - trial.data.correct = selection === trial.data.deviation ? 1 : 0; - - // Increment score if correct - if (trial.data.correct === 1 && trial.name === "main") { - trial.data.score++; + const resetKeyHold = () => { + // Clear the timer + if (keyHoldTimer) { + clearTimeout(keyHoldTimer); + keyHoldTimer = null; + } + + // Reset progress bar if there's a current key + if (currentKey) { + const buttonMapping = { + [keyLayout["1"]]: "vc_l", + [keyLayout["2"]]: "sc_l", + [keyLayout["3"]]: "sc_r", + [keyLayout["4"]]: "vc_r" + }; + + const buttonId = buttonMapping[currentKey]; + if (buttonId) { + const buttonElement = document.querySelector(`[data-button-id="${buttonId}"]`) as HTMLDivElement; + if (buttonElement) { + // Stop and reset the progress bar + renderer.stopProgress(buttonElement); } + } + } - // Continue to the next Stimulus - Runner.post(currentStimulus); + currentKey = null; + }; + + /** + * Handler for post-decision keyup events + * Finishes the post-decision stimulus when all keys are released + */ + const createPostDecisionKeyUpHandler = () => { + const postDecisionStartTime = performance.now(); + + return (event: KeyboardEvent) => { + // Check if this is one of our decision keys + const keycode = event.key.toLowerCase(); + const decisionKeys = [keyLayout["1"], keyLayout["2"], keyLayout["3"], keyLayout["4"]]; + + if (decisionKeys.includes(keycode)) { + // Check if any decision keys are still being held + setTimeout(() => { + if (currentStimulus && currentStimulus.getParameters().name === "post-decision") { + const elapsedTime = performance.now() - postDecisionStartTime; + // Minimum display time: 250ms base + 1000ms if feedback is enabled + const minDisplayTime = trial.showFeedback === true ? 1250 : 250; + + if (elapsedTime < minDisplayTime) { + // Wait for the remaining time before finishing + setTimeout(() => { + if (currentStimulus && currentStimulus.getParameters().name === "post-decision") { + Runner.post(currentStimulus); + } + }, minDisplayTime - elapsedTime); + } else { + // Already waited long enough, finish immediately + Runner.post(currentStimulus); + } + } + }, 50); } - } else { + }; + }; + + /** + * An event handler for decision made during a trial + * @param {KeyboardEvent} event the particular event or keypress + */ + const decisionHandler = (event: KeyboardEvent) => { + // Record the keycode to process the event + const keycode = event.key.toLowerCase(); + + // Filter out invalid keycodes + if (!Object.keys(currentStimulus.getParameters().keybindings).includes( + keycode + )) { console.warn( `Invalid key "${keycode}" for stimulus type "${ currentStimulus.getParameters().name }"` ); + return; + } + + if (currentStimulus.getParameters().name === "decision") { + // Handle 'decision' stimuli + selection = + currentStimulus.getParameters().keybindings[keycode].choice; + currentStimulus.removeKeybindings(); + + // Calculate and store reference data + trial.data.referenceEndTime = performance.now(); + trial.data.referenceTotalTime = + trial.data.referenceEndTime - trial.data.referenceStartTime; + + // Normalize selection data + trial.data.selection = selection; + + // Normalize correct data + trial.data.correct = selection.endsWith(trial.data.deviation) ? 1 : 0; + + // Increment score if correct + if (trial.data.correct === 1 && trial.name === "main") { + trial.data.score++; + } + + // Continue to the next Stimulus + Runner.post(currentStimulus); } }; @@ -452,8 +522,8 @@ class DotGamePlugin implements JsPsychPlugin { // Display of the reference angle and the coloured arcs for // user selection. - const reference = { - name: "reference", + const decision = { + name: "decision", components: [ "outline", "fixation", @@ -473,25 +543,33 @@ class DotGamePlugin implements JsPsychPlugin { post: 0, }, keybindings: { - [keyLayout.left]: { - choice: "left", - handler: decisionHandler, + [keyLayout["1"]]: { + choice: "vc_l", + handler: decisionKeyDownHandler, + }, + [keyLayout["2"]]: { + choice: "sc_l", + handler: decisionKeyDownHandler, }, - [keyLayout.right]: { - choice: "right", - handler: decisionHandler, + [keyLayout["3"]]: { + choice: "sc_r", + handler: decisionKeyDownHandler, + }, + [keyLayout["4"]]: { + choice: "vc_r", + handler: decisionKeyDownHandler, }, }, target: display_element, trial: trial, rendererParameters: rendererParameters, - eventHandler: decisionHandler, + eventHandler: decisionKeyDownHandler, postTrialHandler: nextStimulus, }; // Brief display of the fixation cross. - const decision = { - name: "decision", + const postDecision = { + name: "post-decision", components: ["outline", "fixation"], two: two, renderer: renderer, @@ -500,48 +578,9 @@ class DotGamePlugin implements JsPsychPlugin { selected: false, timing: { pre: 0, - run: 250, - post: 0, - }, - target: display_element, - trial: trial, - rendererParameters: rendererParameters, - eventHandler: decisionHandler, - postTrialHandler: nextStimulus, - }; - - // Display of the confidence slider used to rate confidence. - const confidence = { - name: "confidence", - components: ["confidence"], - two: two, - renderer: renderer, - graphics: graphics, - interactive: true, - selected: false, - timing: { - pre: 0, - run: -1, + run: -1, // Wait indefinitely until all keys are released post: 0, }, - keybindings: { - [keyLayout.submit]: { - choice: "submit", - handler: decisionHandler, - }, - [keyLayout.left]: { - choice: "decrease", - handler: decisionHandler, - }, - [keyLayout.right]: { - choice: "increase", - handler: decisionHandler, - }, - [keyLayout.alt]: { - choice: "mistake", - handler: decisionHandler, - }, - }, target: display_element, trial: trial, rendererParameters: rendererParameters, @@ -554,19 +593,16 @@ class DotGamePlugin implements JsPsychPlugin { stimuli.push( new Stimulus(initial), new Stimulus(motion), - new Stimulus(reference), - new Stimulus(decision) + new Stimulus(decision), + new Stimulus(postDecision) ); - if (trial.checkConfidence === true) { - // Exception for tutorial trials, confidence should be shown - // for all trials - stimuli.push(new Stimulus(confidence)); - } - let currentStimulus = null; trial.data.trialStartTime = performance.now(); + // Make the keyup handler globally accessible + window.decisionKeyUpHandler = decisionKeyUpHandler; + nextStimulus(); } } diff --git a/types/index.d.ts b/types/index.d.ts index 8eac43d..98ca5d7 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -9,7 +9,6 @@ export type RDKTrial = { dotDirection: number; dotVelocity: number; showFeedback: boolean; - checkConfidence: boolean; keyLayout: IKeys; data: { name: string; @@ -17,10 +16,9 @@ export type RDKTrial = { coherence: number; stimulusDuration: number; dotDirection: number; - referenceSelection: string; + selection: "vc_l" | "sc_l" | "sc_r" | "vc_r"; deviation: "left" | "right"; correct: boolean; - confidenceSelection: number; }; };