diff --git a/examples/webgazer.html b/examples/webgazer.html index c37766d7..f910e4fc 100644 --- a/examples/webgazer.html +++ b/examples/webgazer.html @@ -1,174 +1,174 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - \ No newline at end of file + jsPsych.run(timeline); + + diff --git a/examples/webgazer_audio.html b/examples/webgazer_audio.html index d781e1f7..d3fb6d3c 100644 --- a/examples/webgazer_audio.html +++ b/examples/webgazer_audio.html @@ -1,16 +1,16 @@ - - - - - - - + + + + + + + - - + + @@ -18,47 +18,56 @@ \ No newline at end of file diff --git a/examples/webgazer_image.html b/examples/webgazer_image.html index 0b5e3a1b..afd5b732 100644 --- a/examples/webgazer_image.html +++ b/examples/webgazer_image.html @@ -1,60 +1,60 @@ - - - - - - + + + + + + - - + + \ No newline at end of file diff --git a/extensions/jspsych-ext-webgazer.js b/extensions/jspsych-ext-webgazer.js deleted file mode 100644 index cd447214..00000000 --- a/extensions/jspsych-ext-webgazer.js +++ /dev/null @@ -1,265 +0,0 @@ -jsPsych.extensions['webgazer'] = (function () { - - var extension = {}; - - // private state for the extension - // extension authors can define public functions to interact - // with the state. recommend not exposing state directly - // so that state manipulations are checked. - var state = {}; - - // required, will be called at jsPsych.init - // should return a Promise - extension.initialize = function (params) { - // setting default values for params if not defined - params.round_predictions = typeof params.round_predictions === 'undefined' ? true : params.round_predictions; - params.auto_initialize = typeof params.auto_initialize === 'undefined' ? false : params.auto_initialize; - params.sampling_interval = typeof params.sampling_interval === 'undefined' ? 34 : params.sampling_interval; - - return new Promise(function (resolve, reject) { - if (typeof params.webgazer === 'undefined') { - if (window.webgazer) { - state.webgazer = window.webgazer; - } else { - reject(new Error('Webgazer extension failed to initialize. webgazer.js not loaded. Load webgazer.js before calling jsPsych.init()')); - } - } else { - state.webgazer = params.webgazer; - } - - // sets up event handler for webgazer data - state.webgazer.setGazeListener(handleGazeDataUpdate); - - // default to threadedRidge regression - // NEVER MIND... kalman filter is too useful. - //state.webgazer.workerScriptURL = 'js/webgazer/ridgeWorker.mjs'; - //state.webgazer.setRegression('threadedRidge'); - //state.webgazer.applyKalmanFilter(false); // kalman filter doesn't seem to work yet with threadedridge. - - // set state parameters - state.round_predictions = params.round_predictions; - state.sampling_interval = params.sampling_interval; - - // sets state for initialization - state.initialized = false; - state.activeTrial = false; - state.gazeUpdateCallbacks = []; - state.domObserver = new MutationObserver(mutationObserverCallback); - - // hide video by default - extension.hideVideo(); - - // hide predictions by default - extension.hidePredictions(); - - if (params.auto_initialize) { - // starts webgazer, and once it initializes we stop mouseCalibration and - // pause webgazer data. - state.webgazer.begin().then(function () { - state.initialized = true; - extension.stopMouseCalibration(); - extension.pause(); - resolve(); - }).catch(function (error) { - console.error(error); - reject(error); - }); - } else { - resolve(); - } - }) - } - - // required, will be called when the trial starts (before trial loads) - extension.on_start = function (params) { - state.currentTrialData = []; - state.currentTrialTargets = {}; - state.currentTrialSelectors = params.targets; - - state.domObserver.observe(jsPsych.getDisplayElement(), {childList: true}) - - } - - // required will be called when the trial loads - extension.on_load = function (params) { - - // set current trial start time - state.currentTrialStart = performance.now(); - - // resume data collection - // state.webgazer.resume(); - - extension.startSampleInterval(); - - // set internal flag - state.activeTrial = true; - } - - // required, will be called when jsPsych.finishTrial() is called - // must return data object to be merged into data. - extension.on_finish = function (params) { - - // pause the eye tracker - extension.stopSampleInterval(); - - // stop watching the DOM - state.domObserver.disconnect(); - - // state.webgazer.pause(); - - // set internal flag - state.activeTrial = false; - - // send back the gazeData - return { - webgazer_data: state.currentTrialData, - webgazer_targets: state.currentTrialTargets - } - } - - extension.start = function () { - if(typeof state.webgazer == 'undefined'){ - console.error('Failed to start webgazer. Things to check: Is webgazer.js loaded? Is the webgazer extension included in jsPsych.init?') - return; - } - return new Promise(function (resolve, reject) { - state.webgazer.begin().then(function () { - state.initialized = true; - extension.stopMouseCalibration(); - extension.pause(); - resolve(); - }).catch(function (error) { - console.error(error); - reject(error); - }); - }); - } - - extension.startSampleInterval = function(interval){ - interval = typeof interval == 'undefined' ? state.sampling_interval : interval; - state.gazeInterval = setInterval(function(){ - state.webgazer.getCurrentPrediction().then(handleGazeDataUpdate); - }, state.sampling_interval); - // repeat the call here so that we get one immediate execution. above will not - // start until state.sampling_interval is reached the first time. - state.webgazer.getCurrentPrediction().then(handleGazeDataUpdate); - } - - extension.stopSampleInterval = function(){ - clearInterval(state.gazeInterval); - } - - extension.isInitialized = function(){ - return state.initialized; - } - - extension.faceDetected = function () { - return state.webgazer.getTracker().predictionReady; - } - - extension.showPredictions = function () { - state.webgazer.showPredictionPoints(true); - } - - extension.hidePredictions = function () { - state.webgazer.showPredictionPoints(false); - } - - extension.showVideo = function () { - state.webgazer.showVideo(true); - state.webgazer.showFaceOverlay(true); - state.webgazer.showFaceFeedbackBox(true); - } - - extension.hideVideo = function () { - state.webgazer.showVideo(false); - state.webgazer.showFaceOverlay(false); - state.webgazer.showFaceFeedbackBox(false); - } - - extension.resume = function () { - state.webgazer.resume(); - } - - extension.pause = function () { - state.webgazer.pause(); - // sometimes gaze dot will show and freeze after pause? - if(document.querySelector('#webgazerGazeDot')){ - document.querySelector('#webgazerGazeDot').style.display = 'none'; - } - } - - extension.resetCalibration = function(){ - state.webgazer.clearData(); - } - - extension.stopMouseCalibration = function () { - state.webgazer.removeMouseEventListeners() - } - - extension.startMouseCalibration = function () { - state.webgazer.addMouseEventListeners() - } - - extension.calibratePoint = function (x, y) { - state.webgazer.recordScreenPosition(x, y, 'click'); - } - - extension.setRegressionType = function (regression_type) { - var valid_regression_models = ['ridge', 'weightedRidge', 'threadedRidge']; - if (valid_regression_models.includes(regression_type)) { - state.webgazer.setRegression(regression_type) - } else { - console.warn('Invalid regression_type parameter for webgazer.setRegressionType. Valid options are ridge, weightedRidge, and threadedRidge.') - } - } - - extension.getCurrentPrediction = function () { - return state.webgazer.getCurrentPrediction(); - } - - extension.onGazeUpdate = function(callback){ - state.gazeUpdateCallbacks.push(callback); - return function(){ - state.gazeUpdateCallbacks = state.gazeUpdateCallbacks.filter(function(item){ - return item !== callback; - }); - } - } - - function handleGazeDataUpdate(gazeData, elapsedTime) { - if (gazeData !== null){ - var d = { - x: state.round_predictions ? Math.round(gazeData.x) : gazeData.x, - y: state.round_predictions ? Math.round(gazeData.y) : gazeData.y, - t: gazeData.t - } - if(state.activeTrial) { - //console.log(`handleUpdate: t = ${Math.round(gazeData.t)}, now = ${Math.round(performance.now())}`); - d.t = Math.round(gazeData.t - state.currentTrialStart) - state.currentTrialData.push(d); // add data to current trial's data - } - state.currentGaze = d; - for(var i=0; i=7" + } + }, "packages/jspsych": { "version": "7.0.0", "license": "MIT", @@ -13869,6 +13881,10 @@ "@jspsych/config": { "version": "file:packages/config" }, + "@jspsych/extension-webgazer": { + "version": "file:packages/extension-webgazer", + "requires": {} + }, "@jspsych/plugin-animation": { "version": "file:packages/plugin-animation", "requires": {} diff --git a/packages/extension-webgazer/jest.config.cjs b/packages/extension-webgazer/jest.config.cjs new file mode 100644 index 00000000..6ac19d5c --- /dev/null +++ b/packages/extension-webgazer/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/extension-webgazer/package.json b/packages/extension-webgazer/package.json new file mode 100644 index 00000000..79a773d6 --- /dev/null +++ b/packages/extension-webgazer/package.json @@ -0,0 +1,34 @@ +{ + "name": "@jspsych/extension-webgazer", + "version": "1.0.0", + "description": "jsPsych extension for eye tracking using WebGazer.js", + "type": "module", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "files": [ + "src", + "dist" + ], + "source": "src/index.ts", + "scripts": { + "test": "jest --passWithNoTests", + "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.git" + }, + "author": "Josh de Leeuw", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jsPsych/issues" + }, + "homepage": "https://github.com/jspsych/jsPsych#readme", + "peerDependencies": { + "jspsych": ">=7" + } +} diff --git a/packages/extension-webgazer/rollup.config.mjs b/packages/extension-webgazer/rollup.config.mjs new file mode 100644 index 00000000..aebb1f01 --- /dev/null +++ b/packages/extension-webgazer/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfigForPlugin } from "@jspsych/config/rollup.mjs"; + +export default makeRollupConfigForPlugin("jsPsychExtensionWebgazer"); diff --git a/packages/extension-webgazer/src/index.ts b/packages/extension-webgazer/src/index.ts new file mode 100644 index 00000000..4648ad0c --- /dev/null +++ b/packages/extension-webgazer/src/index.ts @@ -0,0 +1,322 @@ +import { JsPsych, JsPsychExtension, JsPsychExtensionInfo } from "jspsych"; + +// we have to add webgazer to the global window object because webgazer attaches itself to +// the window when it loads +declare global { + interface Window { + webgazer: any; + } +} + +interface InitializeParameters { + /** + * Whether to round WebGazer's predicted x, y coordinates to the nearest integer. Recommended + * to leave this as `true` because it saves significant space in the data object and the + * predictions aren't precise to the level of partial pixels. + * @default true + */ + round_predictions: boolean; + /** + * Whether to initialize WebGazer automatically when the plugin loads. Leave this as `false` + * if you plan to initialize WebGazer later in the experiment using a plugin. + * @default false + */ + auto_initialize: boolean; + /** + * The number of milliseconds between each sample. Note that this is only a request, and the + * actual interval will vary depending on processing time. + * @default 34 + */ + sampling_interval: number; + /** + * An instance of WebGazer. If left undefined then the global window.webgazer object will be used + * if it exists. + */ + webgazer: any; +} + +interface OnStartParameters { + targets: Array; +} + +class WebGazerExtension implements JsPsychExtension { + static info: JsPsychExtensionInfo = { + name: "webgazer", + }; + + constructor(private jsPsych: JsPsych) {} + + // private state for the extension + // extension authors can define public functions to interact + // with the state. recommend not exposing state directly + // so that state manipulations are checked. + private currentTrialData = []; + private currentTrialTargets = {}; + private currentTrialSelectors: Array; + private domObserver: MutationObserver; + private webgazer; + private initialized = false; + private currentTrialStart: number; + private activeTrial = false; + private sampling_interval: number; + private round_predictions: boolean; + private gazeInterval: ReturnType; + private gazeUpdateCallbacks: Array; + private currentGaze: Object; + + initialize = ({ + round_predictions = true, + auto_initialize = false, + sampling_interval = 34, + webgazer, + }: InitializeParameters): Promise => { + // set initial state of the extension + this.round_predictions = round_predictions; + this.sampling_interval = sampling_interval; + this.gazeUpdateCallbacks = []; + this.domObserver = new MutationObserver(this.mutationObserverCallback); + + return new Promise((resolve, reject) => { + if (typeof webgazer === "undefined") { + if (window.webgazer) { + this.webgazer = window.webgazer; + } else { + reject( + new Error( + "Webgazer extension failed to initialize. webgazer.js not loaded. Load webgazer.js before calling jsPsych.init()" + ) + ); + } + } else { + this.webgazer = webgazer; + } + + // sets up event handler for webgazer data + // this.webgazer.setGazeListener(this.handleGazeDataUpdate); + + // default to threadedRidge regression + // NEVER MIND... kalman filter is too useful. + //state.webgazer.workerScriptURL = 'js/webgazer/ridgeWorker.mjs'; + //state.webgazer.setRegression('threadedRidge'); + //state.webgazer.applyKalmanFilter(false); // kalman filter doesn't seem to work yet with threadedridge. + + // hide video by default + this.hideVideo(); + + // hide predictions by default + this.hidePredictions(); + + if (auto_initialize) { + // starts webgazer, and once it initializes we stop mouseCalibration and + // pause webgazer data. + this.webgazer + .begin() + .then(() => { + this.initialized = true; + this.stopMouseCalibration(); + this.pause(); + resolve(); + }) + .catch((error) => { + console.error(error); + reject(error); + }); + } else { + resolve(); + } + }); + }; + + on_start = (params: OnStartParameters): void => { + this.currentTrialData = []; + this.currentTrialTargets = {}; + this.currentTrialSelectors = params.targets; + + this.domObserver.observe(this.jsPsych.getDisplayElement(), { childList: true }); + }; + + on_load = () => { + // set current trial start time + this.currentTrialStart = performance.now(); + + // resume data collection + // state.webgazer.resume(); + + this.startSampleInterval(); + + // set internal flag + this.activeTrial = true; + }; + + on_finish = () => { + // pause the eye tracker + this.stopSampleInterval(); + + // stop watching the DOM + this.domObserver.disconnect(); + + // state.webgazer.pause(); + + // set internal flag + this.activeTrial = false; + + // send back the gazeData + return { + webgazer_data: this.currentTrialData, + webgazer_targets: this.currentTrialTargets, + }; + }; + + start = () => { + return new Promise((resolve, reject) => { + if (typeof this.webgazer == "undefined") { + const error = + "Failed to start webgazer. Things to check: Is webgazer.js loaded? Is the webgazer extension included in jsPsych.init?"; + console.error(error); + reject(error); + } + this.webgazer + .begin() + .then(() => { + this.initialized = true; + this.stopMouseCalibration(); + this.pause(); + resolve(); + }) + .catch((error) => { + console.error(error); + reject(error); + }); + }); + }; + + startSampleInterval = (interval: number = this.sampling_interval) => { + this.gazeInterval = setInterval(() => { + this.webgazer.getCurrentPrediction().then(this.handleGazeDataUpdate); + }, interval); + // repeat the call here so that we get one immediate execution. above will not + // start until state.sampling_interval is reached the first time. + this.webgazer.getCurrentPrediction().then(this.handleGazeDataUpdate); + }; + + stopSampleInterval = () => { + clearInterval(this.gazeInterval); + }; + + isInitialized = () => { + return this.initialized; + }; + + faceDetected = () => { + return this.webgazer.getTracker().predictionReady; + }; + + showPredictions = () => { + this.webgazer.showPredictionPoints(true); + }; + + hidePredictions = () => { + this.webgazer.showPredictionPoints(false); + }; + + showVideo = () => { + this.webgazer.showVideo(true); + this.webgazer.showFaceOverlay(true); + this.webgazer.showFaceFeedbackBox(true); + }; + + hideVideo = () => { + this.webgazer.showVideo(false); + this.webgazer.showFaceOverlay(false); + this.webgazer.showFaceFeedbackBox(false); + }; + + resume = () => { + this.webgazer.resume(); + }; + + pause = () => { + this.webgazer.pause(); + // sometimes gaze dot will show and freeze after pause? + if (document.querySelector("#webgazerGazeDot")) { + document.querySelector("#webgazerGazeDot").style.display = "none"; + } + }; + + resetCalibration = () => { + this.webgazer.clearData(); + }; + + stopMouseCalibration = () => { + this.webgazer.removeMouseEventListeners(); + }; + + startMouseCalibration = () => { + this.webgazer.addMouseEventListeners(); + }; + + calibratePoint = (x: number, y: number) => { + this.webgazer.recordScreenPosition(x, y, "click"); + }; + + setRegressionType = (regression_type) => { + var valid_regression_models = ["ridge", "weightedRidge", "threadedRidge"]; + if (valid_regression_models.includes(regression_type)) { + this.webgazer.setRegression(regression_type); + } else { + console.warn( + "Invalid regression_type parameter for webgazer.setRegressionType. Valid options are ridge, weightedRidge, and threadedRidge." + ); + } + }; + + getCurrentPrediction = () => { + return this.webgazer.getCurrentPrediction(); + }; + + onGazeUpdate = (callback) => { + this.gazeUpdateCallbacks.push(callback); + return () => { + this.gazeUpdateCallbacks = this.gazeUpdateCallbacks.filter((item) => { + return item !== callback; + }); + }; + }; + + private handleGazeDataUpdate = (gazeData, elapsedTime) => { + if (gazeData !== null) { + var d = { + x: this.round_predictions ? Math.round(gazeData.x) : gazeData.x, + y: this.round_predictions ? Math.round(gazeData.y) : gazeData.y, + t: gazeData.t, + }; + if (this.activeTrial) { + //console.log(`handleUpdate: t = ${Math.round(gazeData.t)}, now = ${Math.round(performance.now())}`); + d.t = Math.round(gazeData.t - this.currentTrialStart); + this.currentTrialData.push(d); // add data to current trial's data + } + this.currentGaze = d; + for (var i = 0; i < this.gazeUpdateCallbacks.length; i++) { + this.gazeUpdateCallbacks[i](d); + } + } else { + this.currentGaze = null; + } + }; + + private mutationObserverCallback = (mutationsList, observer) => { + for (const selector of this.currentTrialSelectors) { + if (!this.currentTrialTargets[selector]) { + if (this.jsPsych.getDisplayElement().querySelector(selector)) { + var coords = this.jsPsych + .getDisplayElement() + .querySelector(selector) + .getBoundingClientRect(); + this.currentTrialTargets[selector] = coords; + } + } + } + }; +} + +export default WebGazerExtension; diff --git a/packages/extension-webgazer/tsconfig.json b/packages/extension-webgazer/tsconfig.json new file mode 100644 index 00000000..d8051c78 --- /dev/null +++ b/packages/extension-webgazer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@jspsych/config/tsconfig.json", + "include": ["src"] +} diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index fdca1cd4..c895b40f 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -135,6 +135,11 @@ export class JsPsych { this.data = new JsPsychData(this); this.pluginAPI = createJointPluginAPIObject(this); + // create instances of extensions + for (const extension of options.extensions) { + this.extensions[extension.type.info.name] = new extension.type(this); + } + // initialize audio context based on options and browser capabilities this.pluginAPI.initAudio(); } @@ -254,10 +259,8 @@ export class JsPsych { } // handle extension callbacks if (Array.isArray(current_trial.extensions)) { - for (var i = 0; i < current_trial.extensions.length; i++) { - var ext_data_values = this.extensions[current_trial.extensions[i].type].on_finish( - current_trial.extensions[i].params - ); + for (const extension of current_trial.extensions) { + var ext_data_values = this.extensions[extension.type.info.name].on_finish(extension.params); Object.assign(trial_data_values, ext_data_values); } } @@ -438,7 +441,7 @@ export class JsPsych { try { await Promise.all( extensions.map((extension) => - this.extensions[extension.type].initialize(extension.params || {}) + this.extensions[extension.type.info.name].initialize(extension.params || {}) ) ); } catch (error_message) { @@ -536,8 +539,8 @@ export class JsPsych { // call any on_start functions for extensions if (Array.isArray(trial.extensions)) { - for (var i = 0; i < trial.extensions.length; i++) { - this.extensions[trial.extensions[i].type].on_start(this.current_trial.extensions[i].params); + for (const extension of trial.extensions) { + this.extensions[extension.type.info.name].on_start(extension.params); } } @@ -567,8 +570,8 @@ export class JsPsych { // call any on_load functions for extensions if (Array.isArray(trial.extensions)) { - for (var i = 0; i < trial.extensions.length; i++) { - this.extensions[trial.extensions[i].type].on_load(this.current_trial.extensions[i].params); + for (const extension of trial.extensions) { + this.extensions[extension.type.info.name].on_load(extension.params); } } diff --git a/packages/jspsych/src/index.ts b/packages/jspsych/src/index.ts index 1d017e48..e4a1fdce 100755 --- a/packages/jspsych/src/index.ts +++ b/packages/jspsych/src/index.ts @@ -32,3 +32,4 @@ export { universalPluginParameters, UniversalPluginParameters, } from "./modules/plugins"; +export { JsPsychExtension, JsPsychExtensionInfo } from "./modules/extensions"; diff --git a/packages/jspsych/src/modules/extensions.ts b/packages/jspsych/src/modules/extensions.ts new file mode 100644 index 00000000..783e94ae --- /dev/null +++ b/packages/jspsych/src/modules/extensions.ts @@ -0,0 +1,23 @@ +export interface JsPsychExtensionInfo { + name: string; +} + +export interface JsPsychExtension { + /** + * Called once at the start of the experiment to initialize the extension + */ + initialize(params?: Record): Promise; + /** + * Called at the start of a trial, prior to invoking the plugin's trial method. + */ + on_start(params?: Record): void; + /** + * Called during a trial, after the plugin makes initial changes to the DOM. + */ + on_load(params?: Record): void; + /** + * Called at the end of the trial. + * @returns Data to append to the trial's data object. + */ + on_finish(params?: Record): Record; +} diff --git a/packages/jspsych/tests/extensions/extensions.test.ts b/packages/jspsych/tests/extensions/extensions.test.ts index e650fbe0..a1654936 100644 --- a/packages/jspsych/tests/extensions/extensions.test.ts +++ b/packages/jspsych/tests/extensions/extensions.test.ts @@ -4,188 +4,224 @@ import { JsPsych, initJsPsych } from "../../src"; import { pressKey } from "../utils"; import testExtension from "./test-extension"; -let jsPsych: JsPsych; - jest.useFakeTimers(); // https://github.com/jspsych/jsPsych/projects/6#card-64825201 -describe.skip("jsPsych.extensions", () => { - beforeEach(() => { - jsPsych.extensions.test = testExtension; - }); - - test("initialize is called at start of experiment", () => { - var initFunc = jest.spyOn(jsPsych.extensions.test, "initialize"); - - var timeline = [{ type: htmlKeyboardResponse, stimulus: "foo" }]; - - jsPsych = initJsPsych({ - timeline, - extensions: [{ type: "test" }], +describe("jsPsych.extensions", () => { + test("initialize is called at start of experiment", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], }); - expect(initFunc).toHaveBeenCalled(); + expect(typeof jsPsych.extensions.test.initialize).toBe("function"); + + const initFunc = jest.spyOn(jsPsych.extensions.test, "initialize"); + + const timeline = [ + { + type: htmlKeyboardResponse, + stimulus: "foo", + on_load: () => { + pressKey("a"); + }, + on_start: () => { + expect(initFunc).toHaveBeenCalled(); + }, + }, + ]; + + await jsPsych.run(timeline); }); - test("initialize gets params", () => { - var initFunc = jest.spyOn(jsPsych.extensions.test, "initialize"); - - var timeline = [{ type: htmlKeyboardResponse, stimulus: "foo" }]; - - jsPsych = initJsPsych({ - timeline, - extensions: [{ type: "test", params: { foo: 1 } }], + test("initialize gets params", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension, params: { foo: 1 } }], }); - expect(initFunc).toHaveBeenCalledWith({ foo: 1 }); + expect(typeof jsPsych.extensions.test.initialize).toBe("function"); + + const initFunc = jest.spyOn(jsPsych.extensions.test, "initialize"); + + const timeline = [ + { + type: htmlKeyboardResponse, + stimulus: "foo", + on_load: () => { + pressKey("a"); + }, + on_start: () => { + expect(initFunc).toHaveBeenCalledWith({ foo: 1 }); + }, + }, + ]; + + await jsPsych.run(timeline); }); - test("on_start is called before trial", () => { + test("on_start is called before trial", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], + }); + var onStartFunc = jest.spyOn(jsPsych.extensions.test, "on_start"); var trial = { type: htmlKeyboardResponse, stimulus: "foo", - extensions: [{ type: "test" }], + extensions: [{ type: testExtension }], on_load: () => { expect(onStartFunc).toHaveBeenCalled(); + pressKey("a"); }, }; - jsPsych = initJsPsych({ - timeline: [trial], - }); - - pressKey("a"); + await jsPsych.run([trial]); }); - test("on_start gets params", () => { + test("on_start gets params", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], + }); + var onStartFunc = jest.spyOn(jsPsych.extensions.test, "on_start"); var trial = { type: htmlKeyboardResponse, stimulus: "foo", - extensions: [{ type: "test", params: { foo: 1 } }], + extensions: [{ type: testExtension, params: { foo: 1 } }], on_load: () => { expect(onStartFunc).toHaveBeenCalledWith({ foo: 1 }); + pressKey("a"); }, }; - jsPsych = initJsPsych({ - timeline: [trial], - }); - - pressKey("a"); + await jsPsych.run([trial]); }); - test("on_load is called after load", () => { - var onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load"); + test("on_load is called after load", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], + }); - var trial = { + const onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load"); + + const trial = { type: htmlKeyboardResponse, stimulus: "foo", - extensions: [{ type: "test" }], + extensions: [{ type: testExtension }], on_load: () => { // trial load happens before extension load expect(onLoadFunc).not.toHaveBeenCalled(); + pressKey("a"); }, }; - jsPsych = initJsPsych({ - timeline: [trial], - }); + await jsPsych.run([trial]); expect(onLoadFunc).toHaveBeenCalled(); - - pressKey("a"); }); - test("on_load gets params", () => { - var onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load"); - - var trial = { - type: htmlKeyboardResponse, - stimulus: "foo", - extensions: [{ type: "test", params: { foo: 1 } }], - }; - - jsPsych = initJsPsych({ - timeline: [trial], + test("on_load gets params", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], }); - expect(onLoadFunc).toHaveBeenCalledWith({ foo: 1 }); + const onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load"); - pressKey("a"); + const trial = { + type: htmlKeyboardResponse, + stimulus: "foo", + extensions: [{ type: testExtension, params: { foo: 1 } }], + on_load: () => { + pressKey("a"); + }, + }; + + await jsPsych.run([trial]); + + expect(onLoadFunc).toHaveBeenCalledWith({ foo: 1 }); }); - test("on_finish called after trial", () => { + test("on_finish called after trial", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], + }); + var onFinishFunc = jest.spyOn(jsPsych.extensions.test, "on_finish"); var trial = { type: htmlKeyboardResponse, stimulus: "foo", - extensions: [{ type: "test", params: { foo: 1 } }], + extensions: [{ type: testExtension }], + on_load: () => { + expect(onFinishFunc).not.toHaveBeenCalled(); + pressKey("a"); + }, }; - jsPsych = initJsPsych({ - timeline: [trial], - }); - - expect(onFinishFunc).not.toHaveBeenCalled(); - - pressKey("a"); + await jsPsych.run([trial]); expect(onFinishFunc).toHaveBeenCalled(); }); - test("on_finish gets params", () => { + test("on_finish gets params", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], + }); + var onFinishFunc = jest.spyOn(jsPsych.extensions.test, "on_finish"); var trial = { type: htmlKeyboardResponse, stimulus: "foo", - extensions: [{ type: "test", params: { foo: 1 } }], + extensions: [{ type: testExtension, params: { foo: 1 } }], + on_load: () => { + expect(onFinishFunc).not.toHaveBeenCalled(); + pressKey("a"); + }, }; - jsPsych = initJsPsych({ - timeline: [trial], - }); - - pressKey("a"); + await jsPsych.run([trial]); expect(onFinishFunc).toHaveBeenCalledWith({ foo: 1 }); }); - test("on_finish adds trial data", () => { + test("on_finish adds trial data", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], + }); + var trial = { type: htmlKeyboardResponse, stimulus: "foo", - extensions: [{ type: "test", params: { foo: 1 } }], + extensions: [{ type: testExtension }], + on_load: () => { + pressKey("a"); + }, }; - jsPsych = initJsPsych({ - timeline: [trial], - }); - - pressKey("a"); + await jsPsych.run([trial]); expect(jsPsych.data.get().values()[0].extension_data).toBe(true); }); - test("on_finish data is available in trial on_finish", () => { + test("on_finish data is available in trial on_finish", async () => { + const jsPsych = initJsPsych({ + extensions: [{ type: testExtension }], + }); + var trial = { type: htmlKeyboardResponse, stimulus: "foo", - extensions: [{ type: "test", params: { foo: 1 } }], + extensions: [{ type: testExtension }], + on_load: () => { + pressKey("a"); + }, on_finish: (data) => { expect(data.extension_data).toBe(true); }, }; - jsPsych = initJsPsych({ - timeline: [trial], - }); - - pressKey("a"); + await jsPsych.run([trial]); }); }); diff --git a/packages/jspsych/tests/extensions/test-extension.ts b/packages/jspsych/tests/extensions/test-extension.ts index f223c7e4..db38f988 100644 --- a/packages/jspsych/tests/extensions/test-extension.ts +++ b/packages/jspsych/tests/extensions/test-extension.ts @@ -1,32 +1,34 @@ -const extension = {}; +import { JsPsych, JsPsychExtension, JsPsychExtensionParameters } from "jspsych"; -// private state for the extension -// extension authors can define public functions to interact -// with the state. recommend not exposing state directly -// so that state manipulations are checked. -var state = {}; - -// required, will be called at jsPsych.init -// should return a Promise -extension.initialize = (params) => { - return new Promise((resolve, reject) => { - resolve(); - }); -}; - -// required, will be called when the trial starts (before trial loads) -extension.on_start = (params) => {}; - -// required will be called when the trial loads -extension.on_load = (params) => {}; - -// required, will be called when jsPsych.finishTrial() is called -// must return data object to be merged into data. -extension.on_finish = (params) => { - // send back data - return { - extension_data: true, +class TestExtension implements JsPsychExtension { + static info = { + name: "test", }; -}; -export default extension; + constructor(private jsPsych: JsPsych) {} + + // required, will be called at jsPsych.init + // should return a Promise + initialize(params) { + return new Promise((resolve, reject) => { + resolve(); + }); + } + + // required, will be called when the trial starts (before trial loads) + on_start(params) {} + + // required will be called when the trial loads + on_load(params) {} + + // required, will be called when jsPsych.finishTrial() is called + // must return data object to be merged into data. + on_finish(params) { + // send back data + return { + extension_data: true, + }; + } +} + +export default TestExtension; diff --git a/packages/plugin-html-button-response/src/index.ts b/packages/plugin-html-button-response/src/index.ts index 32b05a5a..fa74df7f 100644 --- a/packages/plugin-html-button-response/src/index.ts +++ b/packages/plugin-html-button-response/src/index.ts @@ -1,3 +1,69 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; + +const info = { + name: "html-button-response", + parameters: { + stimulus: { + type: ParameterType.HTML_STRING, + pretty_name: "Stimulus", + default: undefined, + description: "The HTML string to be displayed", + }, + choices: { + type: ParameterType.STRING, + pretty_name: "Choices", + default: undefined, + array: true, + description: "The labels for the buttons.", + }, + button_html: { + type: ParameterType.STRING, + pretty_name: "Button HTML", + default: '', + array: true, + description: "The html of the button. Can create own style.", + }, + prompt: { + type: ParameterType.STRING, + pretty_name: "Prompt", + default: null, + description: "Any content here will be displayed under the button.", + }, + stimulus_duration: { + type: ParameterType.INT, + pretty_name: "Stimulus duration", + default: null, + description: "How long to hide the stimulus.", + }, + trial_duration: { + type: ParameterType.INT, + pretty_name: "Trial duration", + default: null, + description: "How long to show the trial.", + }, + margin_vertical: { + type: ParameterType.STRING, + pretty_name: "Margin vertical", + default: "0px", + description: "The vertical margin of the button.", + }, + margin_horizontal: { + type: ParameterType.STRING, + pretty_name: "Margin horizontal", + default: "8px", + description: "The horizontal margin of the button.", + }, + response_ends_trial: { + type: ParameterType.BOOL, + pretty_name: "Response ends trial", + default: true, + description: "If true, then trial will end when user responds.", + }, + }, +}; + +type Info = typeof info; + /** * jspsych-html-button-response * Josh de Leeuw @@ -7,194 +73,134 @@ * documentation: docs.jspsych.org * **/ +class HtmlButtonResponsePlugin implements JsPsychPlugin { + static info = info; -import jsPsych from "jspsych"; + constructor(private jsPsych: JsPsych) {} -const plugin = {}; + trial(display_element: HTMLElement, trial: TrialType) { + // display stimulus + var html = '
' + trial.stimulus + "
"; -plugin.info = { - name: "html-button-response", - description: "", - parameters: { - stimulus: { - type: jsPsych.plugins.parameterType.HTML_STRING, - pretty_name: "Stimulus", - default: undefined, - description: "The HTML string to be displayed", - }, - choices: { - type: jsPsych.plugins.parameterType.STRING, - pretty_name: "Choices", - default: undefined, - array: true, - description: "The labels for the buttons.", - }, - button_html: { - type: jsPsych.plugins.parameterType.STRING, - pretty_name: "Button HTML", - default: '', - array: true, - description: "The html of the button. Can create own style.", - }, - prompt: { - type: jsPsych.plugins.parameterType.STRING, - pretty_name: "Prompt", - default: null, - description: "Any content here will be displayed under the button.", - }, - stimulus_duration: { - type: jsPsych.plugins.parameterType.INT, - pretty_name: "Stimulus duration", - default: null, - description: "How long to hide the stimulus.", - }, - trial_duration: { - type: jsPsych.plugins.parameterType.INT, - pretty_name: "Trial duration", - default: null, - description: "How long to show the trial.", - }, - margin_vertical: { - type: jsPsych.plugins.parameterType.STRING, - pretty_name: "Margin vertical", - default: "0px", - description: "The vertical margin of the button.", - }, - margin_horizontal: { - type: jsPsych.plugins.parameterType.STRING, - pretty_name: "Margin horizontal", - default: "8px", - description: "The horizontal margin of the button.", - }, - response_ends_trial: { - type: jsPsych.plugins.parameterType.BOOL, - pretty_name: "Response ends trial", - default: true, - description: "If true, then trial will end when user responds.", - }, - }, -}; - -plugin.trial = function (display_element, trial) { - // display stimulus - var html = '
' + trial.stimulus + "
"; - - //display buttons - var buttons = []; - if (Array.isArray(trial.button_html)) { - if (trial.button_html.length == trial.choices.length) { - buttons = trial.button_html; + //display buttons + var buttons = []; + if (Array.isArray(trial.button_html)) { + if (trial.button_html.length == trial.choices.length) { + buttons = trial.button_html; + } else { + console.error( + "Error in html-button-response plugin. The length of the button_html array does not equal the length of the choices array" + ); + } } else { - console.error( - "Error in html-button-response plugin. The length of the button_html array does not equal the length of the choices array" - ); + for (var i = 0; i < trial.choices.length; i++) { + buttons.push(trial.button_html); + } } - } else { + html += '
'; for (var i = 0; i < trial.choices.length; i++) { - buttons.push(trial.button_html); + var str = buttons[i].replace(/%choice%/g, trial.choices[i]); + html += + '
' + + str + + "
"; } - } - html += '
'; - for (var i = 0; i < trial.choices.length; i++) { - var str = buttons[i].replace(/%choice%/g, trial.choices[i]); - html += - '
' + - str + - "
"; - } - html += "
"; + html += "
"; - //show prompt if there is one - if (trial.prompt !== null) { - html += trial.prompt; - } - display_element.innerHTML = html; + //show prompt if there is one + if (trial.prompt !== null) { + html += trial.prompt; + } + display_element.innerHTML = html; - // start time - var start_time = performance.now(); + // start time + var start_time = performance.now(); - // add event listeners to buttons - for (var i = 0; i < trial.choices.length; i++) { - display_element - .querySelector("#jspsych-html-button-response-button-" + i) - .addEventListener("click", function (e) { - var choice = e.currentTarget.getAttribute("data-choice"); // don't use dataset for jsdom compatibility - after_response(choice); - }); - } - - // store response - var response = { - rt: null, - button: null, - }; - - // function to handle responses by the subject - function after_response(choice) { - // measure rt - var end_time = performance.now(); - var rt = end_time - start_time; - response.button = parseInt(choice); - response.rt = rt; - - // after a valid response, the stimulus will have the CSS class 'responded' - // which can be used to provide visual feedback that a response was recorded - display_element.querySelector("#jspsych-html-button-response-stimulus").className += - " responded"; - - // disable all the buttons after a response - var btns = document.querySelectorAll(".jspsych-html-button-response-button button"); - for (var i = 0; i < btns.length; i++) { - //btns[i].removeEventListener('click'); - btns[i].setAttribute("disabled", "disabled"); + // add event listeners to buttons + for (var i = 0; i < trial.choices.length; i++) { + display_element + .querySelector("#jspsych-html-button-response-button-" + i) + .addEventListener("click", function (e) { + var btn_el = e.currentTarget as HTMLButtonElement; + var choice = btn_el.getAttribute("data-choice"); // don't use dataset for jsdom compatibility + after_response(choice); + }); } - if (trial.response_ends_trial) { - end_trial(); - } - } - - // function to end trial when it is time - function end_trial() { - // kill any remaining setTimeout handlers - jsPsych.pluginAPI.clearAllTimeouts(); - - // gather the data to store for the trial - var trial_data = { - rt: response.rt, - stimulus: trial.stimulus, - response: response.button, + // store response + var response = { + rt: null, + button: null, }; - // clear the display - display_element.innerHTML = ""; + // function to end trial when it is time + const end_trial = () => { + // kill any remaining setTimeout handlers + this.jsPsych.pluginAPI.clearAllTimeouts(); - // move on to the next trial - jsPsych.finishTrial(trial_data); + // gather the data to store for the trial + var trial_data = { + rt: response.rt, + stimulus: trial.stimulus, + response: response.button, + }; + + // clear the display + display_element.innerHTML = ""; + + // move on to the next trial + this.jsPsych.finishTrial(trial_data); + }; + + // function to handle responses by the subject + function after_response(choice) { + // measure rt + var end_time = performance.now(); + var rt = end_time - start_time; + response.button = parseInt(choice); + response.rt = rt; + + // after a valid response, the stimulus will have the CSS class 'responded' + // which can be used to provide visual feedback that a response was recorded + display_element.querySelector("#jspsych-html-button-response-stimulus").className += + " responded"; + + // disable all the buttons after a response + var btns = document.querySelectorAll(".jspsych-html-button-response-button button"); + for (var i = 0; i < btns.length; i++) { + //btns[i].removeEventListener('click'); + btns[i].setAttribute("disabled", "disabled"); + } + + if (trial.response_ends_trial) { + end_trial(); + } + } + + // hide image if timing is set + if (trial.stimulus_duration !== null) { + this.jsPsych.pluginAPI.setTimeout(function () { + display_element.querySelector( + "#jspsych-html-button-response-stimulus" + ).style.visibility = "hidden"; + }, trial.stimulus_duration); + } + + // end trial if time limit is set + if (trial.trial_duration !== null) { + this.jsPsych.pluginAPI.setTimeout(function () { + end_trial(); + }, trial.trial_duration); + } } +} - // hide image if timing is set - if (trial.stimulus_duration !== null) { - jsPsych.pluginAPI.setTimeout(function () { - display_element.querySelector("#jspsych-html-button-response-stimulus").style.visibility = - "hidden"; - }, trial.stimulus_duration); - } - - // end trial if time limit is set - if (trial.trial_duration !== null) { - jsPsych.pluginAPI.setTimeout(function () { - end_trial(); - }, trial.trial_duration); - } -}; - -export default plugin; +export default HtmlButtonResponsePlugin; diff --git a/packages/plugin-webgazer-calibrate/src/index.ts b/packages/plugin-webgazer-calibrate/src/index.ts index 75dda61a..40ebfd52 100644 --- a/packages/plugin-webgazer-calibrate/src/index.ts +++ b/packages/plugin-webgazer-calibrate/src/index.ts @@ -1,18 +1,12 @@ -/** - * jspsych-webgazer-calibrate - * Josh de Leeuw - **/ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; -import jsPsych from "jspsych"; - -const plugin = {}; - -plugin.info = { +const info = { name: "webgazer-calibrate", description: "", parameters: { + /* An array of calibration points, where each element is an array cointaining the coordinates for one calibration point: [x,y] */ calibration_points: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: [ [10, 10], [10, 50], @@ -25,141 +19,161 @@ plugin.info = { [90, 90], ], }, + /* Options are 'click' and 'view' */ calibration_mode: { - type: jsPsych.plugins.parameterType.STRING, + type: ParameterType.STRING, default: "click", // options: 'click', 'view' }, + /* Size of the calibration points, in pixels */ point_size: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: 20, }, + /* Number of repetitions per calibration point */ repetitions_per_point: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: 1, }, + /* Whether or not to randomize the calibration point order */ randomize_calibration_order: { - type: jsPsych.plugins.parameterType.BOOL, + type: ParameterType.BOOL, default: false, }, + /* If calibration_mode is view, then this is the delay before calibration after the point is shown */ time_to_saccade: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: 1000, }, + /* If calibration_mode is view, then this is the length of time to show the point while calibrating */ time_per_point: { - type: jsPsych.plugins.parameterType.STRING, + type: ParameterType.INT, default: 1000, }, }, }; -plugin.trial = function (display_element, trial) { - var html = ` -
-
`; +type Info = typeof info; - display_element.innerHTML = html; +/** + * jspsych-webgazer-calibrate + * Josh de Leeuw + **/ +class WebgazerCalibratePlugin implements JsPsychPlugin { + static info = info; - var wg_container = display_element.querySelector("#webgazer-calibrate-container"); + constructor(private jsPsych: JsPsych) {} - var reps_completed = 0; - var points_completed = -1; - var cal_points = null; + trial(display_element: HTMLElement, trial: TrialType) { + var html = ` +
+
`; - calibrate(); + display_element.innerHTML = html; - function calibrate() { - jsPsych.extensions["webgazer"].resume(); - if (trial.calibration_mode == "click") { - jsPsych.extensions["webgazer"].startMouseCalibration(); - } - next_calibration_round(); - } + var wg_container = display_element.querySelector("#webgazer-calibrate-container"); - function next_calibration_round() { - if (trial.randomize_calibration_order) { - cal_points = jsPsych.randomization.shuffle(trial.calibration_points); - } else { - cal_points = trial.calibration_points; - } - points_completed = -1; - next_calibration_point(); - } + var reps_completed = 0; + var points_completed = -1; + var cal_points = null; - function next_calibration_point() { - points_completed++; - if (points_completed == cal_points.length) { - reps_completed++; - if (reps_completed == trial.repetitions_per_point) { - calibration_done(); + const next_calibration_round = () => { + if (trial.randomize_calibration_order) { + cal_points = this.jsPsych.randomization.shuffle(trial.calibration_points); } else { - next_calibration_round(); + cal_points = trial.calibration_points; } - } else { - var pt = cal_points[points_completed]; - calibration_display_gaze_only(pt); - } - } + points_completed = -1; + next_calibration_point(); + }; - function calibration_display_gaze_only(pt) { - var pt_html = `
`; - wg_container.innerHTML = pt_html; + const calibrate = () => { + this.jsPsych.extensions["webgazer"].resume(); + if (trial.calibration_mode == "click") { + this.jsPsych.extensions["webgazer"].startMouseCalibration(); + } + next_calibration_round(); + }; - var pt_dom = wg_container.querySelector("#calibration-point"); - - if (trial.calibration_mode == "click") { - pt_dom.style.cursor = "pointer"; - pt_dom.addEventListener("click", function () { - next_calibration_point(); - }); - } - - if (trial.calibration_mode == "view") { - var br = pt_dom.getBoundingClientRect(); - var x = br.left + br.width / 2; - var y = br.top + br.height / 2; - - var pt_start_cal = performance.now() + trial.time_to_saccade; - var pt_finish = performance.now() + trial.time_to_saccade + trial.time_per_point; - - requestAnimationFrame(function watch_dot() { - if (performance.now() > pt_start_cal) { - jsPsych.extensions["webgazer"].calibratePoint(x, y, "click"); - } - if (performance.now() < pt_finish) { - requestAnimationFrame(watch_dot); + const next_calibration_point = () => { + points_completed++; + if (points_completed == cal_points.length) { + reps_completed++; + if (reps_completed == trial.repetitions_per_point) { + calibration_done(); } else { - next_calibration_point(); + next_calibration_round(); } - }); - } + } else { + var pt = cal_points[points_completed]; + calibration_display_gaze_only(pt); + } + }; + + const calibration_display_gaze_only = (pt) => { + var pt_html = `
`; + wg_container.innerHTML = pt_html; + + var pt_dom = wg_container.querySelector("#calibration-point"); + + if (trial.calibration_mode == "click") { + pt_dom.style.cursor = "pointer"; + pt_dom.addEventListener("click", function () { + next_calibration_point(); + }); + } + + if (trial.calibration_mode == "view") { + var br = pt_dom.getBoundingClientRect(); + var x = br.left + br.width / 2; + var y = br.top + br.height / 2; + + var pt_start_cal: number = performance.now() + trial.time_to_saccade; + var pt_finish: number = performance.now() + trial.time_to_saccade + trial.time_per_point; + + const watch_dot = () => { + if (performance.now() > pt_start_cal) { + this.jsPsych.extensions["webgazer"].calibratePoint(x, y, "click"); + } + if (performance.now() < pt_finish) { + requestAnimationFrame(watch_dot); + } else { + next_calibration_point(); + } + }; + + requestAnimationFrame(watch_dot); + } + }; + + const calibration_done = () => { + if (trial.calibration_mode == "click") { + this.jsPsych.extensions["webgazer"].stopMouseCalibration(); + } + wg_container.innerHTML = ""; + end_trial(); + }; + + // function to end trial when it is time + const end_trial = () => { + this.jsPsych.extensions["webgazer"].pause(); + this.jsPsych.extensions["webgazer"].hidePredictions(); + this.jsPsych.extensions["webgazer"].hideVideo(); + + // kill any remaining setTimeout handlers + this.jsPsych.pluginAPI.clearAllTimeouts(); + + // gather the data to store for the trial + var trial_data = {}; + + // clear the display + display_element.innerHTML = ""; + + // move on to the next trial + this.jsPsych.finishTrial(trial_data); + }; + + calibrate(); } +} - function calibration_done() { - if (trial.calibration_mode == "click") { - jsPsych.extensions["webgazer"].stopMouseCalibration(); - } - wg_container.innerHTML = ""; - end_trial(); - } - - // function to end trial when it is time - function end_trial() { - jsPsych.extensions["webgazer"].pause(); - jsPsych.extensions["webgazer"].hidePredictions(); - jsPsych.extensions["webgazer"].hideVideo(); - - // kill any remaining setTimeout handlers - jsPsych.pluginAPI.clearAllTimeouts(); - - // gather the data to store for the trial - var trial_data = {}; - - // clear the display - display_element.innerHTML = ""; - - // move on to the next trial - jsPsych.finishTrial(trial_data); - } -}; - -export default plugin; +export default WebgazerCalibratePlugin; diff --git a/packages/plugin-webgazer-init-camera/src/index.ts b/packages/plugin-webgazer-init-camera/src/index.ts index 96b53d7f..ebddf8d4 100644 --- a/packages/plugin-webgazer-init-camera/src/index.ts +++ b/packages/plugin-webgazer-init-camera/src/index.ts @@ -1,144 +1,151 @@ -/** - * jspsych-webgazer-init-camera - * Josh de Leeuw - **/ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; -import jsPsych from "jspsych"; - -const plugin = {}; - -plugin.info = { +const info = { name: "webgazer-init-camera", - description: "", parameters: { + /* Instruction text */ instructions: { - type: jsPsych.plugins.parameterType.HTML_STRING, + type: ParameterType.HTML_STRING, default: `

Position your head so that the webcam has a good view of your eyes.

Center your face in the box and look directly towards the camera.

It is important that you try and keep your head reasonably still throughout the experiment, so please take a moment to adjust your setup to be comfortable.

When your face is centered in the box and the box is green, you can click to continue.

`, }, + /* Text for the button that participants click to end the trial. */ button_text: { - type: jsPsych.plugins.parameterType.STRING, + type: ParameterType.STRING, default: "Continue", }, }, }; -plugin.trial = function (display_element, trial) { - var start_time = performance.now(); - var load_time; +type Info = typeof info; - if (!jsPsych.extensions.webgazer.isInitialized()) { - jsPsych.extensions.webgazer - .start() - .then(function () { - showTrial(); - }) - .catch(function () { - display_element.innerHTML = `

The experiment cannot continue because the eye tracker failed to start.

-

This may be because of a technical problem or because you did not grant permission for the page to use your camera.

`; - }); - } else { - showTrial(); - } +/** + * jspsych-webgazer-init-camera + * Josh de Leeuw + **/ +class WebgazerInitCameraPlugin implements JsPsychPlugin { + static info = info; - function showTrial() { - load_time = Math.round(performance.now() - start_time); + constructor(private jsPsych: JsPsych) {} - var style = ` - - `; - document.querySelector("head").insertAdjacentHTML("beforeend", style); + trial(display_element: HTMLElement, trial: TrialType) { + var start_time = performance.now(); + var load_time: number; - var html = ` -
-
`; + // function to end trial when it is time + const end_trial = () => { + this.jsPsych.extensions["webgazer"].pause(); + this.jsPsych.extensions["webgazer"].hideVideo(); - display_element.innerHTML = html; + // kill any remaining setTimeout handlers + this.jsPsych.pluginAPI.clearAllTimeouts(); - jsPsych.extensions["webgazer"].showVideo(); - jsPsych.extensions["webgazer"].resume(); + // gather the data to store for the trial + var trial_data = { + load_time: load_time, + }; - var wg_container = display_element.querySelector("#webgazer-init-container"); + // clear the display + display_element.innerHTML = ""; - wg_container.innerHTML = ` -
- ${trial.instructions} - -
`; + document.querySelector("#webgazer-center-style").remove(); - if (is_face_detect_green()) { - (document.querySelector("#jspsych-wg-cont") as HTMLButtonElement).disabled = false; - } else { - var observer = new MutationObserver(face_detect_event_observer); - observer.observe(document, { - attributes: true, - attributeFilter: ["style"], - subtree: true, - }); - } - - document.querySelector("#jspsych-wg-cont").addEventListener("click", function () { - if (observer) { - observer.disconnect(); - } - end_trial(); - }); - } - - function is_face_detect_green() { - if (document.querySelector("#webgazerFaceFeedbackBox")) { - return ( - (document.querySelector("#webgazerFaceFeedbackBox") as HTMLElement).style.borderColor == - "green" - ); - } else { - return false; - } - } - - function face_detect_event_observer(mutationsList, observer) { - if (mutationsList[0].target == document.querySelector("#webgazerFaceFeedbackBox")) { - if ( - mutationsList[0].type == "attributes" && - mutationsList[0].target.style.borderColor == "green" - ) { - (document.querySelector("#jspsych-wg-cont") as HTMLButtonElement).disabled = false; - } - if ( - mutationsList[0].type == "attributes" && - mutationsList[0].target.style.borderColor == "red" - ) { - (document.querySelector("#jspsych-wg-cont") as HTMLButtonElement).disabled = true; - } - } - } - - // function to end trial when it is time - function end_trial() { - jsPsych.extensions["webgazer"].pause(); - jsPsych.extensions["webgazer"].hideVideo(); - - // kill any remaining setTimeout handlers - jsPsych.pluginAPI.clearAllTimeouts(); - - // gather the data to store for the trial - var trial_data = { - load_time: load_time, + // move on to the next trial + this.jsPsych.finishTrial(trial_data); }; - // clear the display - display_element.innerHTML = ""; + const showTrial = () => { + load_time = Math.round(performance.now() - start_time); - document.querySelector("#webgazer-center-style").remove(); + var style = ` + + `; + document.querySelector("head").insertAdjacentHTML("beforeend", style); - // move on to the next trial - jsPsych.finishTrial(trial_data); + var html = ` +
+
`; + + display_element.innerHTML = html; + + this.jsPsych.extensions["webgazer"].showVideo(); + this.jsPsych.extensions["webgazer"].resume(); + + var wg_container = display_element.querySelector("#webgazer-init-container"); + + wg_container.innerHTML = ` +
+ ${trial.instructions} + +
`; + + if (is_face_detect_green()) { + (document.querySelector("#jspsych-wg-cont") as HTMLButtonElement).disabled = false; + } else { + var observer = new MutationObserver(face_detect_event_observer); + observer.observe(document, { + attributes: true, + attributeFilter: ["style"], + subtree: true, + }); + } + + document.querySelector("#jspsych-wg-cont").addEventListener("click", function () { + if (observer) { + observer.disconnect(); + } + end_trial(); + }); + }; + + if (!this.jsPsych.extensions.webgazer.isInitialized()) { + this.jsPsych.extensions.webgazer + .start() + .then(() => { + showTrial(); + }) + .catch((error) => { + console.log(error); + display_element.innerHTML = `

The experiment cannot continue because the eye tracker failed to start.

+

This may be because of a technical problem or because you did not grant permission for the page to use your camera.

`; + }); + } else { + showTrial(); + } + + function is_face_detect_green() { + if (document.querySelector("#webgazerFaceFeedbackBox")) { + return ( + (document.querySelector("#webgazerFaceFeedbackBox") as HTMLElement).style.borderColor == + "green" + ); + } else { + return false; + } + } + + function face_detect_event_observer(mutationsList, observer) { + if (mutationsList[0].target == document.querySelector("#webgazerFaceFeedbackBox")) { + if ( + mutationsList[0].type == "attributes" && + mutationsList[0].target.style.borderColor == "green" + ) { + (document.querySelector("#jspsych-wg-cont") as HTMLButtonElement).disabled = false; + } + if ( + mutationsList[0].type == "attributes" && + mutationsList[0].target.style.borderColor == "red" + ) { + (document.querySelector("#jspsych-wg-cont") as HTMLButtonElement).disabled = true; + } + } + } } -}; +} -export default plugin; +export default WebgazerInitCameraPlugin; diff --git a/packages/plugin-webgazer-validate/src/index.ts b/packages/plugin-webgazer-validate/src/index.ts index 18246c51..0e309bce 100644 --- a/packages/plugin-webgazer-validate/src/index.ts +++ b/packages/plugin-webgazer-validate/src/index.ts @@ -1,18 +1,14 @@ -/** - * jspsych-webgazer-validate - * Josh de Leeuw - **/ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; -import jsPsych from "jspsych"; +// TO DO: the parameters below don't match up with the docs: https://www.jspsych.org/plugins/jspsych-webgazer-validate/ +// docs contain 'repetiton_per_point', and some param descriptions in docs refer to calibration -const plugin = {}; - -plugin.info = { +const info = { name: "webgazer-validate", - description: "", parameters: { + /* Array of points in [x,y] coordinates */ validation_points: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: [ [10, 10], [10, 50], @@ -24,346 +20,366 @@ plugin.info = { [90, 50], [90, 90], ], + array: true, }, + /* Options are 'percent' and 'center-offset-pixels' */ validation_point_coordinates: { - type: jsPsych.plugins.parameterType.STRING, + type: ParameterType.STRING, default: "percent", // options: 'percent', 'center-offset-pixels' }, + /* Tolerance around validation point in pixels */ roi_radius: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: 200, }, + /* Whether or not to randomize the order of validation points */ randomize_validation_order: { - type: jsPsych.plugins.parameterType.BOOL, + type: ParameterType.BOOL, default: false, }, + /* Delay before validating after showing a point */ time_to_saccade: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: 1000, }, + /* Length of time to show each point */ validation_duration: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: 2000, }, + /* Validation point size in pixels */ point_size: { - type: jsPsych.plugins.parameterType.INT, + type: ParameterType.INT, default: 20, }, + /* If true, then validation data will be shown on the screen after validation is complete */ show_validation_data: { - type: jsPsych.plugins.parameterType.BOOL, + type: ParameterType.BOOL, default: false, }, }, }; -plugin.trial = function (display_element, trial) { - var trial_data = {}; - trial_data.raw_gaze = []; - trial_data.percent_in_roi = []; - trial_data.average_offset = []; - trial_data.validation_points = null; +type Info = typeof info; - var html = ` -
-
`; +/** + * jspsych-webgazer-validate + * Josh de Leeuw + **/ +class WebgazerValidatePlugin implements JsPsychPlugin { + static info = info; - display_element.innerHTML = html; + constructor(private jsPsych: JsPsych) {} - var wg_container = display_element.querySelector("#webgazer-validate-container"); + trial(display_element: HTMLElement, trial: TrialType) { + var trial_data = {}; + trial_data.raw_gaze = []; + trial_data.percent_in_roi = []; + trial_data.average_offset = []; + trial_data.validation_points = null; - var points_completed = -1; - var val_points = null; - var start = performance.now(); - - validate(); - - function validate() { - if (trial.randomize_validation_order) { - val_points = jsPsych.randomization.shuffle(trial.validation_points); - } else { - val_points = trial.validation_points; - } - trial_data.validation_points = val_points; - points_completed = -1; - //jsPsych.extensions['webgazer'].resume(); - jsPsych.extensions.webgazer.startSampleInterval(); - //jsPsych.extensions.webgazer.showPredictions(); - next_validation_point(); - } - - function next_validation_point() { - points_completed++; - if (points_completed == val_points.length) { - validation_done(); - } else { - var pt = val_points[points_completed]; - validation_display(pt); - } - } - - function validation_display(pt) { - var pt_html = drawValidationPoint(pt[0], pt[1]); - wg_container.innerHTML = pt_html; - - var pt_dom = wg_container.querySelector(".validation-point"); - - var br = pt_dom.getBoundingClientRect(); - var x = br.left + br.width / 2; - var y = br.top + br.height / 2; - - var pt_start_val = performance.now() + trial.time_to_saccade; - var pt_finish = pt_start_val + trial.validation_duration; - - var pt_data = []; - - var cancelGazeUpdate = jsPsych.extensions["webgazer"].onGazeUpdate(function (prediction) { - if (performance.now() > pt_start_val) { - pt_data.push({ - x: prediction.x, - y: prediction.y, - dx: prediction.x - x, - dy: prediction.y - y, - t: Math.round(prediction.t - start), - }); - } - }); - - requestAnimationFrame(function watch_dot() { - if (performance.now() < pt_finish) { - requestAnimationFrame(watch_dot); - } else { - trial_data.raw_gaze.push(pt_data); - cancelGazeUpdate(); - - next_validation_point(); - } - }); - } - - // @ts-expect-error - function drawValidationPoint(x, y) { - if (trial.validation_point_coordinates == "percent") { - return drawValidationPoint_PercentMode(x, y); - } - if (trial.validation_point_coordinates == "center-offset-pixels") { - return drawValidationPoint_CenterOffsetMode(x, y); - } - } - - function drawValidationPoint_PercentMode(x, y) { - return `
`; - } - - function drawValidationPoint_CenterOffsetMode(x, y) { - return `
`; - } - - // @ts-expect-error - function drawCircle(target_x, target_y, dx, dy, r) { - if (trial.validation_point_coordinates == "percent") { - return drawCircle_PercentMode(target_x, target_y, dx, dy, r); - } - if (trial.validation_point_coordinates == "center-offset-pixels") { - return drawCircle_CenterOffsetMode(target_x, target_y, dx, dy, r); - } - } - - function drawCircle_PercentMode(target_x, target_y, dx, dy, r) { var html = ` -
- `; - return html; - } +
+
`; - function drawCircle_CenterOffsetMode(target_x, target_y, dx, dy, r) { - var html = ` -
- `; - return html; - } + display_element.innerHTML = html; - // @ts-expect-error - function drawRawDataPoint(target_x, target_y, dx, dy) { - if (trial.validation_point_coordinates == "percent") { - return drawRawDataPoint_PercentMode(target_x, target_y, dx, dy); - } - if (trial.validation_point_coordinates == "center-offset-pixels") { - return drawRawDataPoint_CenterOffsetMode(target_x, target_y, dx, dy); - } - } + var wg_container = display_element.querySelector("#webgazer-validate-container"); - function drawRawDataPoint_PercentMode(target_x, target_y, dx, dy) { - var color = Math.sqrt(dx * dx + dy * dy) <= trial.roi_radius ? "#afa" : "#faa"; - return `
`; - } + var points_completed = -1; + var val_points = null; + var start = performance.now(); - function drawRawDataPoint_CenterOffsetMode(target_x, target_y, dx, dy) { - var color = Math.sqrt(dx * dx + dy * dy) <= trial.roi_radius ? "#afa" : "#faa"; - return `
`; - } + // function to end trial when it is time + const end_trial = () => { + this.jsPsych.extensions.webgazer.stopSampleInterval(); - function median(arr) { - var mid = Math.floor(arr.length / 2); - var sorted_arr = arr.sort((a, b) => a - b); - if (arr.length % 2 == 0) { - return sorted_arr[mid - 1] + sorted_arr[mid] / 2; - } else { - return sorted_arr[mid]; - } - } + // kill any remaining setTimeout handlers + this.jsPsych.pluginAPI.clearAllTimeouts(); - function calculateGazeCentroid(gazeData) { - var x_diff_m = gazeData.reduce(function (accumulator, currentValue, index) { - accumulator += currentValue.dx; - if (index == gazeData.length - 1) { - return accumulator / gazeData.length; - } else { - return accumulator; - } - }, 0); + // clear the display + display_element.innerHTML = ""; - var y_diff_m = gazeData.reduce(function (accumulator, currentValue, index) { - accumulator += currentValue.dy; - if (index == gazeData.length - 1) { - return accumulator / gazeData.length; - } else { - return accumulator; - } - }, 0); - - var median_distance = median( - gazeData.map(function (x) { - return Math.sqrt(Math.pow(x.dx - x_diff_m, 2) + Math.pow(x.dy - y_diff_m, 2)); - }) - ); - - return { - x: x_diff_m, - y: y_diff_m, - r: median_distance, + // move on to the next trial + this.jsPsych.finishTrial(trial_data); }; - } - function calculatePercentInROI(gazeData) { - var distances = gazeData.map(function (p) { - return Math.sqrt(Math.pow(p.dx, 2) + Math.pow(p.dy, 2)); - }); - var sum_in_roi = distances.reduce(function (accumulator, currentValue) { - if (currentValue <= trial.roi_radius) { - accumulator++; - } - return accumulator; - }, 0); - var percent = (sum_in_roi / gazeData.length) * 100; - return percent; - } + const validation_display = (pt) => { + var pt_html = drawValidationPoint(pt[0], pt[1]); + wg_container.innerHTML = pt_html; - function calculateSampleRate(gazeData) { - var mean_diff = []; - for (var i = 0; i < gazeData.length; i++) { - if (gazeData[i].length > 1) { - var t_diff = []; - for (var j = 1; j < gazeData[i].length; j++) { - t_diff.push(gazeData[i][j].t - gazeData[i][j - 1].t); + var pt_dom = wg_container.querySelector(".validation-point"); + + var br = pt_dom.getBoundingClientRect(); + var x = br.left + br.width / 2; + var y = br.top + br.height / 2; + + var pt_start_val = performance.now() + trial.time_to_saccade; + var pt_finish = pt_start_val + trial.validation_duration; + + var pt_data = []; + + var cancelGazeUpdate = this.jsPsych.extensions["webgazer"].onGazeUpdate((prediction) => { + if (performance.now() > pt_start_val) { + pt_data.push({ + x: prediction.x, + y: prediction.y, + dx: prediction.x - x, + dy: prediction.y - y, + t: Math.round(prediction.t - start), + }); } - mean_diff.push( - t_diff.reduce(function (a, b) { - return a + b; - }, 0) / t_diff.length - ); + }); + + requestAnimationFrame(function watch_dot() { + if (performance.now() < pt_finish) { + requestAnimationFrame(watch_dot); + } else { + trial_data.raw_gaze.push(pt_data); + cancelGazeUpdate(); + + next_validation_point(); + } + }); + }; + + const next_validation_point = () => { + points_completed++; + if (points_completed == val_points.length) { + validation_done(); + } else { + var pt = val_points[points_completed]; + validation_display(pt); } - } - if (mean_diff.length > 0) { - return ( - 1000 / - (mean_diff.reduce(function (a, b) { - return a + b; - }, 0) / - mean_diff.length) - ); - } else { - return null; - } - } + }; - function validation_done() { - trial_data.samples_per_sec = calculateSampleRate(trial_data.raw_gaze).toFixed(2); - for (var i = 0; i < trial.validation_points.length; i++) { - trial_data.percent_in_roi[i] = calculatePercentInROI(trial_data.raw_gaze[i]); - trial_data.average_offset[i] = calculateGazeCentroid(trial_data.raw_gaze[i]); - } - if (trial.show_validation_data) { - show_validation_data(); - } else { - end_trial(); - } - } + const validate = () => { + if (trial.randomize_validation_order) { + val_points = this.jsPsych.randomization.shuffle(trial.validation_points); + } else { + val_points = trial.validation_points; + } + trial_data.validation_points = val_points; + points_completed = -1; + //jsPsych.extensions['webgazer'].resume(); + this.jsPsych.extensions.webgazer.startSampleInterval(); + //jsPsych.extensions.webgazer.showPredictions(); + next_validation_point(); + }; - function show_validation_data() { - var html = ""; - for (var i = 0; i < trial.validation_points.length; i++) { - html += drawValidationPoint(trial.validation_points[i][0], trial.validation_points[i][1]); - html += drawCircle( - trial.validation_points[i][0], - trial.validation_points[i][1], - 0, - 0, - trial.roi_radius - ); - for (var j = 0; j < trial_data.raw_gaze[i].length; j++) { - html += drawRawDataPoint( + const show_validation_data = () => { + var html = ""; + for (var i = 0; i < trial.validation_points.length; i++) { + html += drawValidationPoint(trial.validation_points[i][0], trial.validation_points[i][1]); + html += drawCircle( trial.validation_points[i][0], trial.validation_points[i][1], - trial_data.raw_gaze[i][j].dx, - trial_data.raw_gaze[i][j].dy + 0, + 0, + trial.roi_radius ); + for (var j = 0; j < trial_data.raw_gaze[i].length; j++) { + html += drawRawDataPoint( + trial.validation_points[i][0], + trial.validation_points[i][1], + trial_data.raw_gaze[i][j].dx, + trial_data.raw_gaze[i][j].dy + ); + } + } + + html += + '