Create modular versions of extensions (#2053)

Extensions are now passed to the `JsPsych` constructor via an `extensions` option.
Along the way, the webgazer plugins and plugin-html-button-response have been modularized as well.

Co-authored-by: bjoluc <mail@bjoluc.de>
Co-authored-by: Becky Gilbert <beckyannegilbert@gmail.com>
This commit is contained in:
Josh de Leeuw 2021-08-20 05:15:33 -04:00 committed by GitHub
parent c340c5ea55
commit b3b9f5fd5e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1555 additions and 1331 deletions

View File

@ -1,27 +1,33 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<script src="../jspsych.js"></script> <script src="../packages/jspsych/dist/index.browser.js"></script>
<script src="../plugins/jspsych-html-keyboard-response.js"></script> <script src="../packages/plugin-html-keyboard-response/dist/index.browser.js"></script>
<script src="../plugins/jspsych-html-button-response.js"></script> <script src="../packages/plugin-html-button-response/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-init-camera.js"></script> <script src="../packages/plugin-webgazer-init-camera/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-calibrate.js"></script> <script src="../packages/plugin-webgazer-calibrate/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-validate.js"></script> <script src="../packages/plugin-webgazer-validate/dist/index.browser.js"></script>
<script src="js/webgazer/webgazer.js"></script> <script src="js/webgazer/webgazer.js"></script>
<script src="../extensions/jspsych-ext-webgazer.js"></script> <script src="../packages/extension-webgazer/dist/index.browser.js"></script>
<link rel="stylesheet" href="../css/jspsych.css"> <link rel="stylesheet" href="../packages/jspsych/css/jspsych.css" />
<style> <style>
.jspsych-content { max-width: 100%;} .jspsych-content {
max-width: 100%;
}
</style> </style>
</head> </head>
<body></body> <body></body>
<script> <script>
var jsPsych = initJsPsych({
extensions: [
{type: jsPsychExtensionWebgazer}
]
});
//var jsPsych = initJsPsych({extensions: jsPsychWebgazerExtension}); // Not sure how to add webgazer extension to init settings here?
var camera_instructions = { var camera_instructions = {
type: 'html-button-response', type: jsPsychHtmlButtonResponse,
stimulus: ` stimulus: `
<p>This experiment uses your camera for eye tracking.</p> <p>This experiment uses your camera for eye tracking.</p>
<p>In order to participate you must allow the experiment to use your camera.</p> <p>In order to participate you must allow the experiment to use your camera.</p>
@ -33,11 +39,11 @@
} }
var init_camera = { var init_camera = {
type: 'webgazer-init-camera' type: jsPsychWebgazerInitCamera
} }
var calibration_instructions = { var calibration_instructions = {
type: 'html-button-response', type: jsPsychHtmlButtonResponse,
stimulus: ` stimulus: `
<p>Great! Now the eye tracker will be calibrated to translate the image of your eyes from the webcam to a location on your screen.</p> <p>Great! Now the eye tracker will be calibrated to translate the image of your eyes from the webcam to a location on your screen.</p>
<p>To do this, you need to click a series of dots.</p> <p>To do this, you need to click a series of dots.</p>
@ -48,7 +54,7 @@
} }
var calibration = { var calibration = {
type: 'webgazer-calibrate', type: jsPsychWebgazerCalibrate,
calibration_points: [[50,50], [25,25], [25,75], [75,25], [75,75]], calibration_points: [[50,50], [25,25], [25,75], [75,25], [75,75]],
//calibration_points: [[10,10],[10,30],[10,50],[10,70],[10,90],[30,10],[30,30],[30,50],[30,70],[30,90],[50,10],[50,30],[50,50],[50,70],[50,90],[70,10],[70,30],[70,50],[70,70],[70,90],[90,10],[90,30],[90,50],[90,70],[90,90]], //calibration_points: [[10,10],[10,30],[10,50],[10,70],[10,90],[30,10],[30,30],[30,50],[30,70],[30,90],[50,10],[50,30],[50,50],[50,70],[50,90],[70,10],[70,30],[70,50],[70,70],[70,90],[90,10],[90,30],[90,50],[90,70],[90,90]],
// calibration_points: [ // calibration_points: [
@ -59,12 +65,12 @@
// [60,10],[60,30],[60,40],[60,45],[60,50],[60,55],[60,60],[60,70],[60,90], // [60,10],[60,30],[60,40],[60,45],[60,50],[60,55],[60,60],[60,70],[60,90],
// [70,10],[70,50],[70,90], // [70,10],[70,50],[70,90],
// [90,10],[90,50],[90,90]], // [90,10],[90,50],[90,90]],
repetitions_per_point: 3, repetitions_per_point: 2,
randomize_calibration_order: true, randomize_calibration_order: true,
} }
var validation_instructions = { var validation_instructions = {
type: 'html-button-response', type: jsPsychHtmlButtonResponse,
stimulus: ` stimulus: `
<p>Let's see how accurate the eye tracking is. </p> <p>Let's see how accurate the eye tracking is. </p>
<p>Keep your head still, and move your eyes to focus on each dot as it appears.</p> <p>Keep your head still, and move your eyes to focus on each dot as it appears.</p>
@ -75,13 +81,13 @@
} }
var validation = { var validation = {
type: 'webgazer-validate', type: jsPsychWebgazerValidate,
validation_points: [[25,25], [25,75], [75,25], [75,75]], validation_points: [[25,25], [25,75], [75,25], [75,75]],
show_validation_data: true show_validation_data: true
} }
var task_instructions = { var task_instructions = {
type: 'html-button-response', type: jsPsychHtmlButtonResponse,
stimulus: ` stimulus: `
<p>We're ready for the task now.</p> <p>We're ready for the task now.</p>
<p>You'll see an arrow symbol (⬅ or ➡) appear on the screen.</p> <p>You'll see an arrow symbol (⬅ or ➡) appear on the screen.</p>
@ -93,14 +99,14 @@
} }
var fixation = { var fixation = {
type: 'html-keyboard-response', type: jsPsychHtmlKeyboardResponse,
stimulus: '<p style="font-size:40px;">+</p>', stimulus: '<p style="font-size:40px;">+</p>',
choices: jsPsych.NO_KEYS, choices: jsPsych.NO_KEYS,
trial_duration: 500 trial_duration: 500
} }
var trial = { var trial = {
type: 'html-keyboard-response', type: jsPsychHtmlKeyboardResponse,
stimulus: function () { stimulus: function () {
return( return(
`<div style="position: relative; width: 400px; height: 400px;"> `<div style="position: relative; width: 400px; height: 400px;">
@ -117,7 +123,7 @@
left: jsPsych.timelineVariable('left') left: jsPsych.timelineVariable('left')
}, },
extensions: [ extensions: [
{type: 'webgazer', params: {targets: ['#arrow-target']}} {type: jsPsychExtensionWebgazer, params: {targets: ['#arrow-target']}}
] ]
} }
@ -139,7 +145,7 @@
} }
var done = { var done = {
type: 'html-button-response', type: jsPsychHtmlButtonResponse,
choices: ['CSV', 'JSON'], choices: ['CSV', 'JSON'],
stimulus: `<p>Done!</p><p>If you'd like to download a copy of the data to explore, click the format you'd like below</p>`, stimulus: `<p>Done!</p><p>If you'd like to download a copy of the data to explore, click the format you'd like below</p>`,
on_finish: function(data){ on_finish: function(data){
@ -163,12 +169,6 @@
timeline.push(trial_proc); timeline.push(trial_proc);
timeline.push(done); timeline.push(done);
jsPsych.init({ jsPsych.run(timeline);
timeline: timeline,
extensions: [
{type: 'webgazer'}
]
})
</script> </script>
</html> </html>

View File

@ -1,16 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<script src="../jspsych.js"></script> <script src="../packages/jspsych/dist/index.browser.js"></script>
<script src="../plugins/jspsych-preload.js"></script> <script src="../packages/plugin-preload/dist/index.browser.js"></script>
<script src="../plugins/jspsych-audio-keyboard-response.js"></script> <script src="../packages/plugin-audio-keyboard-response/dist/index.browser.js"></script>
<script src="../plugins/jspsych-html-keyboard-response.js"></script> <script src="../packages/plugin-html-keyboard-response/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-init-camera.js"></script> <script src="../packages/plugin-webgazer-init-camera/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-calibrate.js"></script> <script src="../packages/plugin-webgazer-calibrate/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-validate.js"></script> <script src="../packages/plugin-webgazer-validate/dist/index.browser.js"></script>
<script src="js/webgazer/webgazer.js"></script> <script src="js/webgazer/webgazer.js"></script>
<script src="../extensions/jspsych-ext-webgazer.js"></script> <script src="../packages/extension-webgazer/dist/index.browser.js"></script>
<link rel="stylesheet" href="../css/jspsych.css"> <link rel="stylesheet" href="../packages/jspsych/css/jspsych.css">
<style> <style>
.jspsych-content { max-width: 100%;} .jspsych-content { max-width: 100%;}
</style> </style>
@ -18,47 +18,56 @@
<body></body> <body></body>
<script> <script>
var jsPsych = initJsPsych({
extensions: [
{type: jsPsychExtensionWebgazer}
],
on_finish: function() {
jsPsych.data.displayData();
}
})
var preload = { var preload = {
type: 'preload', type: jsPsychPreload,
images: ['img/blue.png', 'img/orange.png'], images: ['img/blue.png', 'img/orange.png'],
audio: ['sound/speech_blue.mp3'] audio: ['sound/speech_blue.mp3']
} }
var init_camera = { var init_camera = {
type: 'webgazer-init-camera' type: jsPsychWebgazerInitCamera
} }
var start_cal = { var start_cal = {
type: 'html-keyboard-response', type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>As each dot appears, look at it and then click on it.</p><p>Press a key to start.</p>' stimulus: '<p>As each dot appears, look at it and then click on it.</p><p>Press a key to start.</p>'
} }
var calibration = { var calibration = {
type: 'webgazer-calibrate', type: jsPsychWebgazerCalibrate,
calibration_points: [ calibration_points: [
[25,25],[25,75],[50,50],[75,75],[75,25] [25,25],[25,75],[50,50],[75,75],[75,25]
] ]
} }
var start_val = { var start_val = {
type: 'html-keyboard-response', type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>As each dot appears, look at it.</p><p>Press a key to start.</p>' stimulus: '<p>As each dot appears, look at it.</p><p>Press a key to start.</p>'
} }
var validation = { var validation = {
type: 'webgazer-validate', type: jsPsychWebgazerValidate,
validation_points: [ validation_points: [
[25,50],[75,50] [25,50],[75,50]
] ]
} }
var start = { var start = {
type: 'html-keyboard-response', type: jsPsychHtmlKeyboardResponse,
stimulus: 'Look at the spoken color. Press a key to start.' stimulus: 'Look at the spoken color. Press a key to start.'
} }
var trial = { var trial = {
type: 'audio-keyboard-response', type: jsPsychAudioKeyboardResponse,
stimulus: 'sound/speech_blue.mp3', stimulus: 'sound/speech_blue.mp3',
prompt: ` prompt: `
<div style="width:100vw; height:300px;"> <div style="width:100vw; height:300px;">
@ -70,21 +79,13 @@ var trial = {
trial_duration: 2000, trial_duration: 2000,
extensions: [ extensions: [
{ {
type: 'webgazer', type: jsPsychExtensionWebgazer,
params: {targets: ['#blue-target', '#orange-target']} params: {targets: ['#blue-target', '#orange-target']}
} }
] ]
} }
jsPsych.init({ jsPsych.run([preload, init_camera, start_cal, calibration, start_val, validation, start, trial])
timeline: [preload, init_camera, start_cal, calibration, start_val, validation, start, trial],
extensions: [
{type: 'webgazer'}
],
on_finish: function() {
jsPsych.data.displayData();
}
})
</script> </script>
</html> </html>

View File

@ -1,60 +1,60 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<script src="../jspsych.js"></script> <script src="../packages/jspsych/dist/index.browser.js"></script>
<script src="../plugins/jspsych-preload.js"></script> <script src="../packages/plugin-preload/dist/index.browser.js"></script>
<script src="../plugins/jspsych-image-keyboard-response.js"></script> <script src="../packages/plugin-image-keyboard-response/dist/index.browser.js"></script>
<script src="../plugins/jspsych-html-keyboard-response.js"></script> <script src="../packages/plugin-html-keyboard-response/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-init-camera.js"></script> <script src="../packages/plugin-webgazer-init-camera/dist/index.browser.js"></script>
<script src="../plugins/jspsych-webgazer-calibrate.js"></script> <script src="../packages/plugin-webgazer-calibrate/dist/index.browser.js"></script>
<script src="js/webgazer/webgazer.js"></script> <script src="js/webgazer/webgazer.js"></script>
<script src="../extensions/jspsych-ext-webgazer.js"></script> <script src="../packages/extension-webgazer/dist/index.browser.js"></script>
<link rel="stylesheet" href="../css/jspsych.css"> <link rel="stylesheet" href="../packages/jspsych/css/jspsych.css" />
</head> </head>
<body></body> <body></body>
<script> <script>
var preload = { var jsPsych = initJsPsych({
type: 'preload',
images: ['img/blue.png']
}
var init_camera = {
type: 'webgazer-init-camera'
}
var validation = {
type: 'webgazer-calibrate',
}
var start = {
type: 'html-keyboard-response',
stimulus: 'Press any key to start.'
}
var trial = {
type: 'image-keyboard-response',
stimulus: 'img/blue.png',
render_on_canvas: false,
choices: jsPsych.NO_KEYS,
trial_duration: 1000,
extensions: [ extensions: [
{ {type: jsPsychExtensionWebgazer}
type: 'webgazer',
params: {targets: ['#jspsych-image-keyboard-response-stimulus']}
}
]
}
jsPsych.init({
timeline: [preload, init_camera, validation, start, trial],
extensions: [
{type: 'webgazer'}
], ],
on_finish: function() { on_finish: function() {
jsPsych.data.displayData(); jsPsych.data.displayData();
} }
}) })
var preload = {
type: jsPsychPreload,
images: ['img/blue.png']
}
var init_camera = {
type: jsPsychWebgazerInitCamera
}
var calibration = {
type: jsPsychWebgazerCalibrate,
}
var start = {
type: jsPsychHtmlKeyboardResponse,
stimulus: 'Press any key to start.'
}
var trial = {
type: jsPsychImageKeyboardResponse,
stimulus: 'img/blue.png',
render_on_canvas: false,
choices: jsPsych.NO_KEYS,
trial_duration: 1000,
extensions: [
{
type: jsPsychExtensionWebgazer,
params: {targets: ['#jspsych-image-keyboard-response-stimulus']}
}
]
}
jsPsych.run([preload, init_camera, validation, start, trial]);
</script> </script>
</html> </html>

View File

@ -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<state.gazeUpdateCallbacks.length; i++){
state.gazeUpdateCallbacks[i](d);
}
} else {
state.currentGaze = null;
}
}
function mutationObserverCallback(mutationsList, observer){
for(const selector of state.currentTrialSelectors){
if(!state.currentTrialTargets[selector]){
if(jsPsych.getDisplayElement().querySelector(selector)){
var coords = jsPsych.getDisplayElement().querySelector(selector).getBoundingClientRect();
state.currentTrialTargets[selector] = coords;
}
}
}
}
return extension;
})();

16
package-lock.json generated
View File

@ -1964,6 +1964,10 @@
"resolved": "packages/config", "resolved": "packages/config",
"link": true "link": true
}, },
"node_modules/@jspsych/extension-webgazer": {
"resolved": "packages/extension-webgazer",
"link": true
},
"node_modules/@jspsych/plugin-animation": { "node_modules/@jspsych/plugin-animation": {
"resolved": "packages/plugin-animation", "resolved": "packages/plugin-animation",
"link": true "link": true
@ -12086,6 +12090,14 @@
"packages/config": { "packages/config": {
"name": "@jspsych/config" "name": "@jspsych/config"
}, },
"packages/extension-webgazer": {
"name": "@jspsych/extension-webgazer",
"version": "1.0.0",
"license": "MIT",
"peerDependencies": {
"jspsych": ">=7"
}
},
"packages/jspsych": { "packages/jspsych": {
"version": "7.0.0", "version": "7.0.0",
"license": "MIT", "license": "MIT",
@ -13869,6 +13881,10 @@
"@jspsych/config": { "@jspsych/config": {
"version": "file:packages/config" "version": "file:packages/config"
}, },
"@jspsych/extension-webgazer": {
"version": "file:packages/extension-webgazer",
"requires": {}
},
"@jspsych/plugin-animation": { "@jspsych/plugin-animation": {
"version": "file:packages/plugin-animation", "version": "file:packages/plugin-animation",
"requires": {} "requires": {}

View File

@ -0,0 +1 @@
module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname);

View File

@ -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"
}
}

View File

@ -0,0 +1,3 @@
import { makeRollupConfigForPlugin } from "@jspsych/config/rollup.mjs";
export default makeRollupConfigForPlugin("jsPsychExtensionWebgazer");

View File

@ -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<string>;
}
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<string>;
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<typeof setInterval>;
private gazeUpdateCallbacks: Array<any>;
private currentGaze: Object;
initialize = ({
round_predictions = true,
auto_initialize = false,
sampling_interval = 34,
webgazer,
}: InitializeParameters): Promise<void> => {
// 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<void>((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<HTMLElement>("#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;

View File

@ -0,0 +1,4 @@
{
"extends": "@jspsych/config/tsconfig.json",
"include": ["src"]
}

View File

@ -135,6 +135,11 @@ export class JsPsych {
this.data = new JsPsychData(this); this.data = new JsPsychData(this);
this.pluginAPI = createJointPluginAPIObject(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 // initialize audio context based on options and browser capabilities
this.pluginAPI.initAudio(); this.pluginAPI.initAudio();
} }
@ -254,10 +259,8 @@ export class JsPsych {
} }
// handle extension callbacks // handle extension callbacks
if (Array.isArray(current_trial.extensions)) { if (Array.isArray(current_trial.extensions)) {
for (var i = 0; i < current_trial.extensions.length; i++) { for (const extension of current_trial.extensions) {
var ext_data_values = this.extensions[current_trial.extensions[i].type].on_finish( var ext_data_values = this.extensions[extension.type.info.name].on_finish(extension.params);
current_trial.extensions[i].params
);
Object.assign(trial_data_values, ext_data_values); Object.assign(trial_data_values, ext_data_values);
} }
} }
@ -438,7 +441,7 @@ export class JsPsych {
try { try {
await Promise.all( await Promise.all(
extensions.map((extension) => extensions.map((extension) =>
this.extensions[extension.type].initialize(extension.params || {}) this.extensions[extension.type.info.name].initialize(extension.params || {})
) )
); );
} catch (error_message) { } catch (error_message) {
@ -536,8 +539,8 @@ export class JsPsych {
// call any on_start functions for extensions // call any on_start functions for extensions
if (Array.isArray(trial.extensions)) { if (Array.isArray(trial.extensions)) {
for (var i = 0; i < trial.extensions.length; i++) { for (const extension of trial.extensions) {
this.extensions[trial.extensions[i].type].on_start(this.current_trial.extensions[i].params); this.extensions[extension.type.info.name].on_start(extension.params);
} }
} }
@ -567,8 +570,8 @@ export class JsPsych {
// call any on_load functions for extensions // call any on_load functions for extensions
if (Array.isArray(trial.extensions)) { if (Array.isArray(trial.extensions)) {
for (var i = 0; i < trial.extensions.length; i++) { for (const extension of trial.extensions) {
this.extensions[trial.extensions[i].type].on_load(this.current_trial.extensions[i].params); this.extensions[extension.type.info.name].on_load(extension.params);
} }
} }

View File

@ -32,3 +32,4 @@ export {
universalPluginParameters, universalPluginParameters,
UniversalPluginParameters, UniversalPluginParameters,
} from "./modules/plugins"; } from "./modules/plugins";
export { JsPsychExtension, JsPsychExtensionInfo } from "./modules/extensions";

View File

@ -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<string, any>): Promise<void>;
/**
* Called at the start of a trial, prior to invoking the plugin's trial method.
*/
on_start(params?: Record<string, any>): void;
/**
* Called during a trial, after the plugin makes initial changes to the DOM.
*/
on_load(params?: Record<string, any>): void;
/**
* Called at the end of the trial.
* @returns Data to append to the trial's data object.
*/
on_finish(params?: Record<string, any>): Record<string, any>;
}

View File

@ -4,188 +4,224 @@ import { JsPsych, initJsPsych } from "../../src";
import { pressKey } from "../utils"; import { pressKey } from "../utils";
import testExtension from "./test-extension"; import testExtension from "./test-extension";
let jsPsych: JsPsych;
jest.useFakeTimers(); jest.useFakeTimers();
// https://github.com/jspsych/jsPsych/projects/6#card-64825201 // https://github.com/jspsych/jsPsych/projects/6#card-64825201
describe.skip("jsPsych.extensions", () => { describe("jsPsych.extensions", () => {
beforeEach(() => { test("initialize is called at start of experiment", async () => {
jsPsych.extensions.test = testExtension; const jsPsych = initJsPsych({
extensions: [{ type: testExtension }],
}); });
test("initialize is called at start of experiment", () => { expect(typeof jsPsych.extensions.test.initialize).toBe("function");
var initFunc = jest.spyOn(jsPsych.extensions.test, "initialize");
var timeline = [{ type: htmlKeyboardResponse, stimulus: "foo" }]; const initFunc = jest.spyOn(jsPsych.extensions.test, "initialize");
jsPsych = initJsPsych({
timeline,
extensions: [{ type: "test" }],
});
const timeline = [
{
type: htmlKeyboardResponse,
stimulus: "foo",
on_load: () => {
pressKey("a");
},
on_start: () => {
expect(initFunc).toHaveBeenCalled(); expect(initFunc).toHaveBeenCalled();
},
},
];
await jsPsych.run(timeline);
}); });
test("initialize gets params", () => { test("initialize gets params", async () => {
var initFunc = jest.spyOn(jsPsych.extensions.test, "initialize"); const jsPsych = initJsPsych({
extensions: [{ type: testExtension, params: { foo: 1 } }],
var timeline = [{ type: htmlKeyboardResponse, stimulus: "foo" }];
jsPsych = initJsPsych({
timeline,
extensions: [{ type: "test", params: { 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 }); expect(initFunc).toHaveBeenCalledWith({ foo: 1 });
},
},
];
await jsPsych.run(timeline);
});
test("on_start is called before trial", async () => {
const jsPsych = initJsPsych({
extensions: [{ type: testExtension }],
}); });
test("on_start is called before trial", () => {
var onStartFunc = jest.spyOn(jsPsych.extensions.test, "on_start"); var onStartFunc = jest.spyOn(jsPsych.extensions.test, "on_start");
var trial = { var trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test" }], extensions: [{ type: testExtension }],
on_load: () => { on_load: () => {
expect(onStartFunc).toHaveBeenCalled(); expect(onStartFunc).toHaveBeenCalled();
pressKey("a");
}, },
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
}); });
pressKey("a"); test("on_start gets params", async () => {
const jsPsych = initJsPsych({
extensions: [{ type: testExtension }],
}); });
test("on_start gets params", () => {
var onStartFunc = jest.spyOn(jsPsych.extensions.test, "on_start"); var onStartFunc = jest.spyOn(jsPsych.extensions.test, "on_start");
var trial = { var trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test", params: { foo: 1 } }], extensions: [{ type: testExtension, params: { foo: 1 } }],
on_load: () => { on_load: () => {
expect(onStartFunc).toHaveBeenCalledWith({ foo: 1 }); expect(onStartFunc).toHaveBeenCalledWith({ foo: 1 });
pressKey("a");
}, },
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
}); });
pressKey("a"); test("on_load is called after load", async () => {
const jsPsych = initJsPsych({
extensions: [{ type: testExtension }],
}); });
test("on_load is called after load", () => { const onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load");
var onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load");
var trial = { const trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test" }], extensions: [{ type: testExtension }],
on_load: () => { on_load: () => {
// trial load happens before extension load // trial load happens before extension load
expect(onLoadFunc).not.toHaveBeenCalled(); expect(onLoadFunc).not.toHaveBeenCalled();
pressKey("a");
}, },
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
});
expect(onLoadFunc).toHaveBeenCalled(); expect(onLoadFunc).toHaveBeenCalled();
pressKey("a");
}); });
test("on_load gets params", () => { test("on_load gets params", async () => {
var onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load"); const jsPsych = initJsPsych({
extensions: [{ type: testExtension }],
});
var trial = { const onLoadFunc = jest.spyOn(jsPsych.extensions.test, "on_load");
const trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test", params: { foo: 1 } }], extensions: [{ type: testExtension, params: { foo: 1 } }],
on_load: () => {
pressKey("a");
},
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
});
expect(onLoadFunc).toHaveBeenCalledWith({ foo: 1 }); expect(onLoadFunc).toHaveBeenCalledWith({ foo: 1 });
pressKey("a");
}); });
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 onFinishFunc = jest.spyOn(jsPsych.extensions.test, "on_finish");
var trial = { var trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test", params: { foo: 1 } }], extensions: [{ type: testExtension }],
on_load: () => {
expect(onFinishFunc).not.toHaveBeenCalled();
pressKey("a");
},
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
});
expect(onFinishFunc).not.toHaveBeenCalled();
pressKey("a");
expect(onFinishFunc).toHaveBeenCalled(); 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 onFinishFunc = jest.spyOn(jsPsych.extensions.test, "on_finish");
var trial = { var trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test", params: { foo: 1 } }], extensions: [{ type: testExtension, params: { foo: 1 } }],
on_load: () => {
expect(onFinishFunc).not.toHaveBeenCalled();
pressKey("a");
},
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
});
pressKey("a");
expect(onFinishFunc).toHaveBeenCalledWith({ foo: 1 }); 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 = { var trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test", params: { foo: 1 } }], extensions: [{ type: testExtension }],
on_load: () => {
pressKey("a");
},
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
});
pressKey("a");
expect(jsPsych.data.get().values()[0].extension_data).toBe(true); 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 = { var trial = {
type: htmlKeyboardResponse, type: htmlKeyboardResponse,
stimulus: "foo", stimulus: "foo",
extensions: [{ type: "test", params: { foo: 1 } }], extensions: [{ type: testExtension }],
on_load: () => {
pressKey("a");
},
on_finish: (data) => { on_finish: (data) => {
expect(data.extension_data).toBe(true); expect(data.extension_data).toBe(true);
}, },
}; };
jsPsych = initJsPsych({ await jsPsych.run([trial]);
timeline: [trial],
});
pressKey("a");
}); });
}); });

View File

@ -1,32 +1,34 @@
const extension = <any>{}; import { JsPsych, JsPsychExtension, JsPsychExtensionParameters } from "jspsych";
// private state for the extension class TestExtension implements JsPsychExtension {
// extension authors can define public functions to interact static info = {
// with the state. recommend not exposing state directly name: "test",
// so that state manipulations are checked. };
var state = {};
constructor(private jsPsych: JsPsych) {}
// required, will be called at jsPsych.init // required, will be called at jsPsych.init
// should return a Promise // should return a Promise
extension.initialize = (params) => { initialize(params) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
resolve(); resolve();
}); });
}; }
// required, will be called when the trial starts (before trial loads) // required, will be called when the trial starts (before trial loads)
extension.on_start = (params) => {}; on_start(params) {}
// required will be called when the trial loads // required will be called when the trial loads
extension.on_load = (params) => {}; on_load(params) {}
// required, will be called when jsPsych.finishTrial() is called // required, will be called when jsPsych.finishTrial() is called
// must return data object to be merged into data. // must return data object to be merged into data.
extension.on_finish = (params) => { on_finish(params) {
// send back data // send back data
return { return {
extension_data: true, extension_data: true,
}; };
}; }
}
export default extension; export default TestExtension;

View File

@ -1,3 +1,69 @@
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
const info = <const>{
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: '<button class="jspsych-btn">%choice%</button>',
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 * jspsych-html-button-response
* Josh de Leeuw * Josh de Leeuw
@ -7,75 +73,12 @@
* documentation: docs.jspsych.org * documentation: docs.jspsych.org
* *
**/ **/
class HtmlButtonResponsePlugin implements JsPsychPlugin<Info> {
static info = info;
import jsPsych from "jspsych"; constructor(private jsPsych: JsPsych) {}
const plugin = <any>{}; trial(display_element: HTMLElement, trial: TrialType<Info>) {
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: '<button class="jspsych-btn">%choice%</button>',
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 // display stimulus
var html = '<div id="jspsych-html-button-response-stimulus">' + trial.stimulus + "</div>"; var html = '<div id="jspsych-html-button-response-stimulus">' + trial.stimulus + "</div>";
@ -126,7 +129,8 @@ plugin.trial = function (display_element, trial) {
display_element display_element
.querySelector("#jspsych-html-button-response-button-" + i) .querySelector("#jspsych-html-button-response-button-" + i)
.addEventListener("click", function (e) { .addEventListener("click", function (e) {
var choice = e.currentTarget.getAttribute("data-choice"); // don't use dataset for jsdom compatibility var btn_el = e.currentTarget as HTMLButtonElement;
var choice = btn_el.getAttribute("data-choice"); // don't use dataset for jsdom compatibility
after_response(choice); after_response(choice);
}); });
} }
@ -137,6 +141,25 @@ plugin.trial = function (display_element, trial) {
button: null, button: null,
}; };
// function to end trial when it is time
const end_trial = () => {
// kill any remaining setTimeout handlers
this.jsPsych.pluginAPI.clearAllTimeouts();
// 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 to handle responses by the subject
function after_response(choice) { function after_response(choice) {
// measure rt // measure rt
@ -162,39 +185,22 @@ plugin.trial = function (display_element, 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,
};
// clear the display
display_element.innerHTML = "";
// move on to the next trial
jsPsych.finishTrial(trial_data);
}
// hide image if timing is set // hide image if timing is set
if (trial.stimulus_duration !== null) { if (trial.stimulus_duration !== null) {
jsPsych.pluginAPI.setTimeout(function () { this.jsPsych.pluginAPI.setTimeout(function () {
display_element.querySelector("#jspsych-html-button-response-stimulus").style.visibility = display_element.querySelector<HTMLElement>(
"hidden"; "#jspsych-html-button-response-stimulus"
).style.visibility = "hidden";
}, trial.stimulus_duration); }, trial.stimulus_duration);
} }
// end trial if time limit is set // end trial if time limit is set
if (trial.trial_duration !== null) { if (trial.trial_duration !== null) {
jsPsych.pluginAPI.setTimeout(function () { this.jsPsych.pluginAPI.setTimeout(function () {
end_trial(); end_trial();
}, trial.trial_duration); }, trial.trial_duration);
} }
}; }
}
export default plugin; export default HtmlButtonResponsePlugin;

View File

@ -1,18 +1,12 @@
/** import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
* jspsych-webgazer-calibrate
* Josh de Leeuw
**/
import jsPsych from "jspsych"; const info = <const>{
const plugin = <any>{};
plugin.info = {
name: "webgazer-calibrate", name: "webgazer-calibrate",
description: "", description: "",
parameters: { parameters: {
/* An array of calibration points, where each element is an array cointaining the coordinates for one calibration point: [x,y] */
calibration_points: { calibration_points: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: [ default: [
[10, 10], [10, 10],
[10, 50], [10, 50],
@ -25,34 +19,51 @@ plugin.info = {
[90, 90], [90, 90],
], ],
}, },
/* Options are 'click' and 'view' */
calibration_mode: { calibration_mode: {
type: jsPsych.plugins.parameterType.STRING, type: ParameterType.STRING,
default: "click", // options: 'click', 'view' default: "click", // options: 'click', 'view'
}, },
/* Size of the calibration points, in pixels */
point_size: { point_size: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: 20, default: 20,
}, },
/* Number of repetitions per calibration point */
repetitions_per_point: { repetitions_per_point: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: 1, default: 1,
}, },
/* Whether or not to randomize the calibration point order */
randomize_calibration_order: { randomize_calibration_order: {
type: jsPsych.plugins.parameterType.BOOL, type: ParameterType.BOOL,
default: false, default: false,
}, },
/* If calibration_mode is view, then this is the delay before calibration after the point is shown */
time_to_saccade: { time_to_saccade: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: 1000, default: 1000,
}, },
/* If calibration_mode is view, then this is the length of time to show the point while calibrating */
time_per_point: { time_per_point: {
type: jsPsych.plugins.parameterType.STRING, type: ParameterType.INT,
default: 1000, default: 1000,
}, },
}, },
}; };
plugin.trial = function (display_element, trial) { type Info = typeof info;
/**
* jspsych-webgazer-calibrate
* Josh de Leeuw
**/
class WebgazerCalibratePlugin implements JsPsychPlugin<Info> {
static info = info;
constructor(private jsPsych: JsPsych) {}
trial(display_element: HTMLElement, trial: TrialType<Info>) {
var html = ` var html = `
<div id='webgazer-calibrate-container' style='position: relative; width:100vw; height:100vh'> <div id='webgazer-calibrate-container' style='position: relative; width:100vw; height:100vh'>
</div>`; </div>`;
@ -65,27 +76,25 @@ plugin.trial = function (display_element, trial) {
var points_completed = -1; var points_completed = -1;
var cal_points = null; var cal_points = null;
calibrate(); const next_calibration_round = () => {
function calibrate() {
jsPsych.extensions["webgazer"].resume();
if (trial.calibration_mode == "click") {
jsPsych.extensions["webgazer"].startMouseCalibration();
}
next_calibration_round();
}
function next_calibration_round() {
if (trial.randomize_calibration_order) { if (trial.randomize_calibration_order) {
cal_points = jsPsych.randomization.shuffle(trial.calibration_points); cal_points = this.jsPsych.randomization.shuffle(trial.calibration_points);
} else { } else {
cal_points = trial.calibration_points; cal_points = trial.calibration_points;
} }
points_completed = -1; points_completed = -1;
next_calibration_point(); next_calibration_point();
} };
function next_calibration_point() { const calibrate = () => {
this.jsPsych.extensions["webgazer"].resume();
if (trial.calibration_mode == "click") {
this.jsPsych.extensions["webgazer"].startMouseCalibration();
}
next_calibration_round();
};
const next_calibration_point = () => {
points_completed++; points_completed++;
if (points_completed == cal_points.length) { if (points_completed == cal_points.length) {
reps_completed++; reps_completed++;
@ -98,13 +107,13 @@ plugin.trial = function (display_element, trial) {
var pt = cal_points[points_completed]; var pt = cal_points[points_completed];
calibration_display_gaze_only(pt); calibration_display_gaze_only(pt);
} }
} };
function calibration_display_gaze_only(pt) { const calibration_display_gaze_only = (pt) => {
var pt_html = `<div id="calibration-point" style="width:${trial.point_size}px; height:${trial.point_size}px; border-radius:${trial.point_size}px; border: 1px solid #000; background-color: #333; position: absolute; left:${pt[0]}%; top:${pt[1]}%;"></div>`; var pt_html = `<div id="calibration-point" style="width:${trial.point_size}px; height:${trial.point_size}px; border-radius:${trial.point_size}px; border: 1px solid #000; background-color: #333; position: absolute; left:${pt[0]}%; top:${pt[1]}%;"></div>`;
wg_container.innerHTML = pt_html; wg_container.innerHTML = pt_html;
var pt_dom = wg_container.querySelector("#calibration-point"); var pt_dom = wg_container.querySelector<HTMLElement>("#calibration-point");
if (trial.calibration_mode == "click") { if (trial.calibration_mode == "click") {
pt_dom.style.cursor = "pointer"; pt_dom.style.cursor = "pointer";
@ -118,38 +127,40 @@ plugin.trial = function (display_element, trial) {
var x = br.left + br.width / 2; var x = br.left + br.width / 2;
var y = br.top + br.height / 2; var y = br.top + br.height / 2;
var pt_start_cal = performance.now() + trial.time_to_saccade; var pt_start_cal: number = performance.now() + trial.time_to_saccade;
var pt_finish = performance.now() + trial.time_to_saccade + trial.time_per_point; var pt_finish: number = performance.now() + trial.time_to_saccade + trial.time_per_point;
requestAnimationFrame(function watch_dot() { const watch_dot = () => {
if (performance.now() > pt_start_cal) { if (performance.now() > pt_start_cal) {
jsPsych.extensions["webgazer"].calibratePoint(x, y, "click"); this.jsPsych.extensions["webgazer"].calibratePoint(x, y, "click");
} }
if (performance.now() < pt_finish) { if (performance.now() < pt_finish) {
requestAnimationFrame(watch_dot); requestAnimationFrame(watch_dot);
} else { } else {
next_calibration_point(); next_calibration_point();
} }
}); };
}
}
function calibration_done() { requestAnimationFrame(watch_dot);
}
};
const calibration_done = () => {
if (trial.calibration_mode == "click") { if (trial.calibration_mode == "click") {
jsPsych.extensions["webgazer"].stopMouseCalibration(); this.jsPsych.extensions["webgazer"].stopMouseCalibration();
} }
wg_container.innerHTML = ""; wg_container.innerHTML = "";
end_trial(); end_trial();
} };
// function to end trial when it is time // function to end trial when it is time
function end_trial() { const end_trial = () => {
jsPsych.extensions["webgazer"].pause(); this.jsPsych.extensions["webgazer"].pause();
jsPsych.extensions["webgazer"].hidePredictions(); this.jsPsych.extensions["webgazer"].hidePredictions();
jsPsych.extensions["webgazer"].hideVideo(); this.jsPsych.extensions["webgazer"].hideVideo();
// kill any remaining setTimeout handlers // kill any remaining setTimeout handlers
jsPsych.pluginAPI.clearAllTimeouts(); this.jsPsych.pluginAPI.clearAllTimeouts();
// gather the data to store for the trial // gather the data to store for the trial
var trial_data = {}; var trial_data = {};
@ -158,8 +169,11 @@ plugin.trial = function (display_element, trial) {
display_element.innerHTML = ""; display_element.innerHTML = "";
// move on to the next trial // move on to the next trial
jsPsych.finishTrial(trial_data); this.jsPsych.finishTrial(trial_data);
}
}; };
export default plugin; calibrate();
}
}
export default WebgazerCalibratePlugin;

View File

@ -1,50 +1,63 @@
/** import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
* jspsych-webgazer-init-camera
* Josh de Leeuw
**/
import jsPsych from "jspsych"; const info = <const>{
const plugin = <any>{};
plugin.info = {
name: "webgazer-init-camera", name: "webgazer-init-camera",
description: "",
parameters: { parameters: {
/* Instruction text */
instructions: { instructions: {
type: jsPsych.plugins.parameterType.HTML_STRING, type: ParameterType.HTML_STRING,
default: ` default: `
<p>Position your head so that the webcam has a good view of your eyes.</p> <p>Position your head so that the webcam has a good view of your eyes.</p>
<p>Center your face in the box and look directly towards the camera.</p> <p>Center your face in the box and look directly towards the camera.</p>
<p>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.</p> <p>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.</p>
<p>When your face is centered in the box and the box is green, you can click to continue.</p>`, <p>When your face is centered in the box and the box is green, you can click to continue.</p>`,
}, },
/* Text for the button that participants click to end the trial. */
button_text: { button_text: {
type: jsPsych.plugins.parameterType.STRING, type: ParameterType.STRING,
default: "Continue", default: "Continue",
}, },
}, },
}; };
plugin.trial = function (display_element, trial) { type Info = typeof info;
/**
* jspsych-webgazer-init-camera
* Josh de Leeuw
**/
class WebgazerInitCameraPlugin implements JsPsychPlugin<Info> {
static info = info;
constructor(private jsPsych: JsPsych) {}
trial(display_element: HTMLElement, trial: TrialType<Info>) {
var start_time = performance.now(); var start_time = performance.now();
var load_time; var load_time: number;
if (!jsPsych.extensions.webgazer.isInitialized()) { // function to end trial when it is time
jsPsych.extensions.webgazer const end_trial = () => {
.start() this.jsPsych.extensions["webgazer"].pause();
.then(function () { this.jsPsych.extensions["webgazer"].hideVideo();
showTrial();
})
.catch(function () {
display_element.innerHTML = `<p>The experiment cannot continue because the eye tracker failed to start.</p>
<p>This may be because of a technical problem or because you did not grant permission for the page to use your camera.</p>`;
});
} else {
showTrial();
}
function showTrial() { // kill any remaining setTimeout handlers
this.jsPsych.pluginAPI.clearAllTimeouts();
// gather the data to store for the trial
var trial_data = {
load_time: load_time,
};
// clear the display
display_element.innerHTML = "";
document.querySelector("#webgazer-center-style").remove();
// move on to the next trial
this.jsPsych.finishTrial(trial_data);
};
const showTrial = () => {
load_time = Math.round(performance.now() - start_time); load_time = Math.round(performance.now() - start_time);
var style = ` var style = `
@ -60,8 +73,8 @@ plugin.trial = function (display_element, trial) {
display_element.innerHTML = html; display_element.innerHTML = html;
jsPsych.extensions["webgazer"].showVideo(); this.jsPsych.extensions["webgazer"].showVideo();
jsPsych.extensions["webgazer"].resume(); this.jsPsych.extensions["webgazer"].resume();
var wg_container = display_element.querySelector("#webgazer-init-container"); var wg_container = display_element.querySelector("#webgazer-init-container");
@ -88,6 +101,21 @@ plugin.trial = function (display_element, trial) {
} }
end_trial(); end_trial();
}); });
};
if (!this.jsPsych.extensions.webgazer.isInitialized()) {
this.jsPsych.extensions.webgazer
.start()
.then(() => {
showTrial();
})
.catch((error) => {
console.log(error);
display_element.innerHTML = `<p>The experiment cannot continue because the eye tracker failed to start.</p>
<p>This may be because of a technical problem or because you did not grant permission for the page to use your camera.</p>`;
});
} else {
showTrial();
} }
function is_face_detect_green() { function is_face_detect_green() {
@ -117,28 +145,7 @@ plugin.trial = function (display_element, trial) {
} }
} }
} }
// 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,
};
// clear the display
display_element.innerHTML = "";
document.querySelector("#webgazer-center-style").remove();
// move on to the next trial
jsPsych.finishTrial(trial_data);
} }
}; }
export default plugin; export default WebgazerInitCameraPlugin;

View File

@ -1,18 +1,14 @@
/** import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
* jspsych-webgazer-validate
* Josh de Leeuw
**/
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 = <any>{}; const info = <const>{
plugin.info = {
name: "webgazer-validate", name: "webgazer-validate",
description: "",
parameters: { parameters: {
/* Array of points in [x,y] coordinates */
validation_points: { validation_points: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: [ default: [
[10, 10], [10, 10],
[10, 50], [10, 50],
@ -24,39 +20,58 @@ plugin.info = {
[90, 50], [90, 50],
[90, 90], [90, 90],
], ],
array: true,
}, },
/* Options are 'percent' and 'center-offset-pixels' */
validation_point_coordinates: { validation_point_coordinates: {
type: jsPsych.plugins.parameterType.STRING, type: ParameterType.STRING,
default: "percent", // options: 'percent', 'center-offset-pixels' default: "percent", // options: 'percent', 'center-offset-pixels'
}, },
/* Tolerance around validation point in pixels */
roi_radius: { roi_radius: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: 200, default: 200,
}, },
/* Whether or not to randomize the order of validation points */
randomize_validation_order: { randomize_validation_order: {
type: jsPsych.plugins.parameterType.BOOL, type: ParameterType.BOOL,
default: false, default: false,
}, },
/* Delay before validating after showing a point */
time_to_saccade: { time_to_saccade: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: 1000, default: 1000,
}, },
/* Length of time to show each point */
validation_duration: { validation_duration: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: 2000, default: 2000,
}, },
/* Validation point size in pixels */
point_size: { point_size: {
type: jsPsych.plugins.parameterType.INT, type: ParameterType.INT,
default: 20, default: 20,
}, },
/* If true, then validation data will be shown on the screen after validation is complete */
show_validation_data: { show_validation_data: {
type: jsPsych.plugins.parameterType.BOOL, type: ParameterType.BOOL,
default: false, default: false,
}, },
}, },
}; };
plugin.trial = function (display_element, trial) { type Info = typeof info;
/**
* jspsych-webgazer-validate
* Josh de Leeuw
**/
class WebgazerValidatePlugin implements JsPsychPlugin<Info> {
static info = info;
constructor(private jsPsych: JsPsych) {}
trial(display_element: HTMLElement, trial: TrialType<Info>) {
var trial_data = <any>{}; var trial_data = <any>{};
trial_data.raw_gaze = []; trial_data.raw_gaze = [];
trial_data.percent_in_roi = []; trial_data.percent_in_roi = [];
@ -75,33 +90,21 @@ plugin.trial = function (display_element, trial) {
var val_points = null; var val_points = null;
var start = performance.now(); var start = performance.now();
validate(); // function to end trial when it is time
const end_trial = () => {
this.jsPsych.extensions.webgazer.stopSampleInterval();
function validate() { // kill any remaining setTimeout handlers
if (trial.randomize_validation_order) { this.jsPsych.pluginAPI.clearAllTimeouts();
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() { // clear the display
points_completed++; display_element.innerHTML = "";
if (points_completed == val_points.length) {
validation_done();
} else {
var pt = val_points[points_completed];
validation_display(pt);
}
}
function validation_display(pt) { // move on to the next trial
this.jsPsych.finishTrial(trial_data);
};
const validation_display = (pt) => {
var pt_html = drawValidationPoint(pt[0], pt[1]); var pt_html = drawValidationPoint(pt[0], pt[1]);
wg_container.innerHTML = pt_html; wg_container.innerHTML = pt_html;
@ -116,7 +119,7 @@ plugin.trial = function (display_element, trial) {
var pt_data = []; var pt_data = [];
var cancelGazeUpdate = jsPsych.extensions["webgazer"].onGazeUpdate(function (prediction) { var cancelGazeUpdate = this.jsPsych.extensions["webgazer"].onGazeUpdate((prediction) => {
if (performance.now() > pt_start_val) { if (performance.now() > pt_start_val) {
pt_data.push({ pt_data.push({
x: prediction.x, x: prediction.x,
@ -138,7 +141,80 @@ plugin.trial = function (display_element, trial) {
next_validation_point(); 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);
} }
};
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();
};
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],
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 +=
'<button id="cont" style="position:absolute; top: 50%; left:calc(50% - 50px); width: 100px;" class="jspsych-btn">Continue</btn>';
wg_container.innerHTML = html;
wg_container.querySelector("#cont").addEventListener("click", () => {
this.jsPsych.extensions.webgazer.pause();
end_trial();
});
// turn on webgazer's loop
this.jsPsych.extensions.webgazer.showPredictions();
this.jsPsych.extensions.webgazer.stopSampleInterval();
this.jsPsych.extensions.webgazer.resume();
};
const 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();
}
};
validate();
// @ts-expect-error // @ts-expect-error
function drawValidationPoint(x, y) { function drawValidationPoint(x, y) {
@ -303,67 +379,7 @@ plugin.trial = function (display_element, trial) {
return null; 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();
} }
} }
function show_validation_data() { export default WebgazerValidatePlugin;
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(
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 +=
'<button id="cont" style="position:absolute; top: 50%; left:calc(50% - 50px); width: 100px;" class="jspsych-btn">Continue</btn>';
wg_container.innerHTML = html;
wg_container.querySelector("#cont").addEventListener("click", function () {
jsPsych.extensions.webgazer.pause();
end_trial();
});
// turn on webgazer's loop
jsPsych.extensions.webgazer.showPredictions();
jsPsych.extensions.webgazer.stopSampleInterval();
jsPsych.extensions.webgazer.resume();
}
// function to end trial when it is time
function end_trial() {
jsPsych.extensions.webgazer.stopSampleInterval();
// kill any remaining setTimeout handlers
jsPsych.pluginAPI.clearAllTimeouts();
// clear the display
display_element.innerHTML = "";
// move on to the next trial
jsPsych.finishTrial(trial_data);
}
};
export default plugin;