mirror of
https://github.com/jspsych/jsPsych.git
synced 2025-05-10 11:10:54 +00:00
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:
parent
c340c5ea55
commit
b3b9f5fd5e
@ -1,174 +1,174 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
<head>
|
||||||
<head>
|
<script src="../packages/jspsych/dist/index.browser.js"></script>
|
||||||
<script src="../jspsych.js"></script>
|
<script src="../packages/plugin-html-keyboard-response/dist/index.browser.js"></script>
|
||||||
<script src="../plugins/jspsych-html-keyboard-response.js"></script>
|
<script src="../packages/plugin-html-button-response/dist/index.browser.js"></script>
|
||||||
<script src="../plugins/jspsych-html-button-response.js"></script>
|
<script src="../packages/plugin-webgazer-init-camera/dist/index.browser.js"></script>
|
||||||
<script src="../plugins/jspsych-webgazer-init-camera.js"></script>
|
<script src="../packages/plugin-webgazer-calibrate/dist/index.browser.js"></script>
|
||||||
<script src="../plugins/jspsych-webgazer-calibrate.js"></script>
|
<script src="../packages/plugin-webgazer-validate/dist/index.browser.js"></script>
|
||||||
<script src="../plugins/jspsych-webgazer-validate.js"></script>
|
<script src="js/webgazer/webgazer.js"></script>
|
||||||
<script src="js/webgazer/webgazer.js"></script>
|
<script src="../packages/extension-webgazer/dist/index.browser.js"></script>
|
||||||
<script src="../extensions/jspsych-ext-webgazer.js"></script>
|
<link rel="stylesheet" href="../packages/jspsych/css/jspsych.css" />
|
||||||
<link rel="stylesheet" href="../css/jspsych.css">
|
<style>
|
||||||
<style>
|
.jspsych-content {
|
||||||
.jspsych-content { max-width: 100%;}
|
max-width: 100%;
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body></body>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
var camera_instructions = {
|
|
||||||
type: 'html-button-response',
|
|
||||||
stimulus: `
|
|
||||||
<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>You will be prompted to do this on the next screen.</p>
|
|
||||||
<p>If you do not want to permit the experiment to use your camera, please close the page.</p>
|
|
||||||
`,
|
|
||||||
choices: ['Click to begin'],
|
|
||||||
post_trial_gap: 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
var init_camera = {
|
|
||||||
type: 'webgazer-init-camera'
|
|
||||||
}
|
|
||||||
|
|
||||||
var calibration_instructions = {
|
|
||||||
type: 'html-button-response',
|
|
||||||
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>To do this, you need to click a series of dots.</p>
|
|
||||||
<p>Keep your head still, and click on each dot as it appears. Look at the dot as you click it.</p>
|
|
||||||
`,
|
|
||||||
choices: ['Click to begin'],
|
|
||||||
post_trial_gap: 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
var calibration = {
|
|
||||||
type: 'webgazer-calibrate',
|
|
||||||
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,50],[10,90],
|
|
||||||
// [30,10],[30,50],[30,90],
|
|
||||||
// [40,10],[40,30],[40,40],[40,45],[40,50],[40,55],[40,60],[40,70],[40,90],
|
|
||||||
// [50,10],[50,30],[50,40],[50,45],[50,50],[50,55],[50,60],[50,70],[50,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],
|
|
||||||
// [90,10],[90,50],[90,90]],
|
|
||||||
repetitions_per_point: 3,
|
|
||||||
randomize_calibration_order: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var validation_instructions = {
|
|
||||||
type: 'html-button-response',
|
|
||||||
stimulus: `
|
|
||||||
<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>You do not need to click on the dots. Just move your eyes to look at the dots.</p>
|
|
||||||
`,
|
|
||||||
choices: ['Click to begin'],
|
|
||||||
post_trial_gap: 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
var validation = {
|
|
||||||
type: 'webgazer-validate',
|
|
||||||
validation_points: [[25,25], [25,75], [75,25], [75,75]],
|
|
||||||
show_validation_data: true
|
|
||||||
}
|
|
||||||
|
|
||||||
var task_instructions = {
|
|
||||||
type: 'html-button-response',
|
|
||||||
stimulus: `
|
|
||||||
<p>We're ready for the task now.</p>
|
|
||||||
<p>You'll see an arrow symbol (⬅ or ➡) appear on the screen.</p>
|
|
||||||
<p>Your job is to press A if ⬅ appears, and L if ➡ appears.</p>
|
|
||||||
<p>This will repeat 8 times.</p>
|
|
||||||
`,
|
|
||||||
choices: ['I am ready!'],
|
|
||||||
post_trial_gap: 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
var fixation = {
|
|
||||||
type: 'html-keyboard-response',
|
|
||||||
stimulus: '<p style="font-size:40px;">+</p>',
|
|
||||||
choices: jsPsych.NO_KEYS,
|
|
||||||
trial_duration: 500
|
|
||||||
}
|
|
||||||
|
|
||||||
var trial = {
|
|
||||||
type: 'html-keyboard-response',
|
|
||||||
stimulus: function () {
|
|
||||||
return(
|
|
||||||
`<div style="position: relative; width: 400px; height: 400px;">
|
|
||||||
<div style="position: absolute; top:${jsPsych.timelineVariable('top', true)}%; left: ${jsPsych.timelineVariable('left', true)}%">
|
|
||||||
<span id="arrow-target" style="font-size: 40px; transform: translate(-50%, -50%);">${jsPsych.timelineVariable('direction', true) == 'left' ? '⬅' : '➡'}</span>
|
|
||||||
</div>
|
|
||||||
</div>`
|
|
||||||
)
|
|
||||||
},
|
|
||||||
choices: ['a', 'l'],
|
|
||||||
post_trial_gap: 750,
|
|
||||||
data: {
|
|
||||||
top: jsPsych.timelineVariable('top'),
|
|
||||||
left: jsPsych.timelineVariable('left')
|
|
||||||
},
|
|
||||||
extensions: [
|
|
||||||
{type: 'webgazer', params: {targets: ['#arrow-target']}}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
var params = [
|
|
||||||
{ left: 0, top: 0, direction: 'left' },
|
|
||||||
{ left: 100, top: 0, direction: 'left' },
|
|
||||||
{ left: 0, top: 100, direction: 'left' },
|
|
||||||
{ left: 100, top: 100, direction: 'left' },
|
|
||||||
{ left: 0, top: 0, direction: 'right' },
|
|
||||||
{ left: 100, top: 0, direction: 'right' },
|
|
||||||
{ left: 0, top: 100, direction: 'right' },
|
|
||||||
{ left: 100, top: 100, direction: 'right' },
|
|
||||||
]
|
|
||||||
|
|
||||||
var trial_proc = {
|
|
||||||
timeline: [fixation, trial],
|
|
||||||
timeline_variables: params,
|
|
||||||
randomize_order: true
|
|
||||||
}
|
|
||||||
|
|
||||||
var done = {
|
|
||||||
type: 'html-button-response',
|
|
||||||
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>`,
|
|
||||||
on_finish: function(data){
|
|
||||||
if(data.response == 0){
|
|
||||||
jsPsych.data.get().localSave('csv','webgazer-sample-data.csv');
|
|
||||||
}
|
}
|
||||||
if(data.response == 1){
|
</style>
|
||||||
jsPsych.data.get().localSave('json', 'webgazer-sample-data.json');
|
</head>
|
||||||
|
<body></body>
|
||||||
|
<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 = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: `
|
||||||
|
<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>You will be prompted to do this on the next screen.</p>
|
||||||
|
<p>If you do not want to permit the experiment to use your camera, please close the page.</p>
|
||||||
|
`,
|
||||||
|
choices: ['Click to begin'],
|
||||||
|
post_trial_gap: 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
var init_camera = {
|
||||||
|
type: jsPsychWebgazerInitCamera
|
||||||
|
}
|
||||||
|
|
||||||
|
var calibration_instructions = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
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>To do this, you need to click a series of dots.</p>
|
||||||
|
<p>Keep your head still, and click on each dot as it appears. Look at the dot as you click it.</p>
|
||||||
|
`,
|
||||||
|
choices: ['Click to begin'],
|
||||||
|
post_trial_gap: 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
var calibration = {
|
||||||
|
type: jsPsychWebgazerCalibrate,
|
||||||
|
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,50],[10,90],
|
||||||
|
// [30,10],[30,50],[30,90],
|
||||||
|
// [40,10],[40,30],[40,40],[40,45],[40,50],[40,55],[40,60],[40,70],[40,90],
|
||||||
|
// [50,10],[50,30],[50,40],[50,45],[50,50],[50,55],[50,60],[50,70],[50,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],
|
||||||
|
// [90,10],[90,50],[90,90]],
|
||||||
|
repetitions_per_point: 2,
|
||||||
|
randomize_calibration_order: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var validation_instructions = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: `
|
||||||
|
<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>You do not need to click on the dots. Just move your eyes to look at the dots.</p>
|
||||||
|
`,
|
||||||
|
choices: ['Click to begin'],
|
||||||
|
post_trial_gap: 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
var validation = {
|
||||||
|
type: jsPsychWebgazerValidate,
|
||||||
|
validation_points: [[25,25], [25,75], [75,25], [75,75]],
|
||||||
|
show_validation_data: true
|
||||||
|
}
|
||||||
|
|
||||||
|
var task_instructions = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: `
|
||||||
|
<p>We're ready for the task now.</p>
|
||||||
|
<p>You'll see an arrow symbol (⬅ or ➡) appear on the screen.</p>
|
||||||
|
<p>Your job is to press A if ⬅ appears, and L if ➡ appears.</p>
|
||||||
|
<p>This will repeat 8 times.</p>
|
||||||
|
`,
|
||||||
|
choices: ['I am ready!'],
|
||||||
|
post_trial_gap: 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixation = {
|
||||||
|
type: jsPsychHtmlKeyboardResponse,
|
||||||
|
stimulus: '<p style="font-size:40px;">+</p>',
|
||||||
|
choices: jsPsych.NO_KEYS,
|
||||||
|
trial_duration: 500
|
||||||
|
}
|
||||||
|
|
||||||
|
var trial = {
|
||||||
|
type: jsPsychHtmlKeyboardResponse,
|
||||||
|
stimulus: function () {
|
||||||
|
return(
|
||||||
|
`<div style="position: relative; width: 400px; height: 400px;">
|
||||||
|
<div style="position: absolute; top:${jsPsych.timelineVariable('top', true)}%; left: ${jsPsych.timelineVariable('left', true)}%">
|
||||||
|
<span id="arrow-target" style="font-size: 40px; transform: translate(-50%, -50%);">${jsPsych.timelineVariable('direction', true) == 'left' ? '⬅' : '➡'}</span>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
choices: ['a', 'l'],
|
||||||
|
post_trial_gap: 750,
|
||||||
|
data: {
|
||||||
|
top: jsPsych.timelineVariable('top'),
|
||||||
|
left: jsPsych.timelineVariable('left')
|
||||||
|
},
|
||||||
|
extensions: [
|
||||||
|
{type: jsPsychExtensionWebgazer, params: {targets: ['#arrow-target']}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = [
|
||||||
|
{ left: 0, top: 0, direction: 'left' },
|
||||||
|
{ left: 100, top: 0, direction: 'left' },
|
||||||
|
{ left: 0, top: 100, direction: 'left' },
|
||||||
|
{ left: 100, top: 100, direction: 'left' },
|
||||||
|
{ left: 0, top: 0, direction: 'right' },
|
||||||
|
{ left: 100, top: 0, direction: 'right' },
|
||||||
|
{ left: 0, top: 100, direction: 'right' },
|
||||||
|
{ left: 100, top: 100, direction: 'right' },
|
||||||
|
]
|
||||||
|
|
||||||
|
var trial_proc = {
|
||||||
|
timeline: [fixation, trial],
|
||||||
|
timeline_variables: params,
|
||||||
|
randomize_order: true
|
||||||
|
}
|
||||||
|
|
||||||
|
var done = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
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>`,
|
||||||
|
on_finish: function(data){
|
||||||
|
if(data.response == 0){
|
||||||
|
jsPsych.data.get().localSave('csv','webgazer-sample-data.csv');
|
||||||
|
}
|
||||||
|
if(data.response == 1){
|
||||||
|
jsPsych.data.get().localSave('json', 'webgazer-sample-data.json');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var timeline = [];
|
var timeline = [];
|
||||||
timeline.push(camera_instructions);
|
timeline.push(camera_instructions);
|
||||||
timeline.push(init_camera);
|
timeline.push(init_camera);
|
||||||
timeline.push(calibration_instructions);
|
timeline.push(calibration_instructions);
|
||||||
timeline.push(calibration);
|
timeline.push(calibration);
|
||||||
timeline.push(validation_instructions);
|
timeline.push(validation_instructions);
|
||||||
timeline.push(validation);
|
timeline.push(validation);
|
||||||
timeline.push(task_instructions);
|
timeline.push(task_instructions);
|
||||||
timeline.push(trial_proc);
|
timeline.push(trial_proc);
|
||||||
timeline.push(done);
|
timeline.push(done);
|
||||||
|
|
||||||
jsPsych.init({
|
jsPsych.run(timeline);
|
||||||
timeline: timeline,
|
</script>
|
||||||
extensions: [
|
</html>
|
||||||
{type: 'webgazer'}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
|
@ -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>
|
@ -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>
|
@ -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
16
package-lock.json
generated
@ -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": {}
|
||||||
|
1
packages/extension-webgazer/jest.config.cjs
Normal file
1
packages/extension-webgazer/jest.config.cjs
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname);
|
34
packages/extension-webgazer/package.json
Normal file
34
packages/extension-webgazer/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
3
packages/extension-webgazer/rollup.config.mjs
Normal file
3
packages/extension-webgazer/rollup.config.mjs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { makeRollupConfigForPlugin } from "@jspsych/config/rollup.mjs";
|
||||||
|
|
||||||
|
export default makeRollupConfigForPlugin("jsPsychExtensionWebgazer");
|
322
packages/extension-webgazer/src/index.ts
Normal file
322
packages/extension-webgazer/src/index.ts
Normal 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;
|
4
packages/extension-webgazer/tsconfig.json
Normal file
4
packages/extension-webgazer/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "@jspsych/config/tsconfig.json",
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,3 +32,4 @@ export {
|
|||||||
universalPluginParameters,
|
universalPluginParameters,
|
||||||
UniversalPluginParameters,
|
UniversalPluginParameters,
|
||||||
} from "./modules/plugins";
|
} from "./modules/plugins";
|
||||||
|
export { JsPsychExtension, JsPsychExtensionInfo } from "./modules/extensions";
|
||||||
|
23
packages/jspsych/src/modules/extensions.ts
Normal file
23
packages/jspsych/src/modules/extensions.ts
Normal 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>;
|
||||||
|
}
|
@ -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", () => {
|
|
||||||
var initFunc = jest.spyOn(jsPsych.extensions.test, "initialize");
|
|
||||||
|
|
||||||
var timeline = [{ type: htmlKeyboardResponse, stimulus: "foo" }];
|
|
||||||
|
|
||||||
jsPsych = initJsPsych({
|
|
||||||
timeline,
|
|
||||||
extensions: [{ type: "test" }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
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(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 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", () => {
|
test("on_start gets params", async () => {
|
||||||
|
const jsPsych = initJsPsych({
|
||||||
|
extensions: [{ type: testExtension }],
|
||||||
|
});
|
||||||
|
|
||||||
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", () => {
|
test("on_load is called after load", 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" }],
|
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 = {
|
|
||||||
type: htmlKeyboardResponse,
|
|
||||||
stimulus: "foo",
|
|
||||||
extensions: [{ type: "test", params: { foo: 1 } }],
|
|
||||||
};
|
|
||||||
|
|
||||||
jsPsych = initJsPsych({
|
|
||||||
timeline: [trial],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 = {};
|
|
||||||
|
|
||||||
// required, will be called at jsPsych.init
|
|
||||||
// should return a Promise
|
|
||||||
extension.initialize = (params) => {
|
|
||||||
return new Promise<void>((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,
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
export default extension;
|
constructor(private jsPsych: JsPsych) {}
|
||||||
|
|
||||||
|
// required, will be called at jsPsych.init
|
||||||
|
// should return a Promise
|
||||||
|
initialize(params) {
|
||||||
|
return new Promise<void>((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;
|
||||||
|
@ -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,194 +73,134 @@
|
|||||||
* 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>) {
|
||||||
|
// display stimulus
|
||||||
|
var html = '<div id="jspsych-html-button-response-stimulus">' + trial.stimulus + "</div>";
|
||||||
|
|
||||||
plugin.info = {
|
//display buttons
|
||||||
name: "html-button-response",
|
var buttons = [];
|
||||||
description: "",
|
if (Array.isArray(trial.button_html)) {
|
||||||
parameters: {
|
if (trial.button_html.length == trial.choices.length) {
|
||||||
stimulus: {
|
buttons = trial.button_html;
|
||||||
type: jsPsych.plugins.parameterType.HTML_STRING,
|
} else {
|
||||||
pretty_name: "Stimulus",
|
console.error(
|
||||||
default: undefined,
|
"Error in html-button-response plugin. The length of the button_html array does not equal the length of the choices array"
|
||||||
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
|
|
||||||
var html = '<div id="jspsych-html-button-response-stimulus">' + trial.stimulus + "</div>";
|
|
||||||
|
|
||||||
//display buttons
|
|
||||||
var buttons = [];
|
|
||||||
if (Array.isArray(trial.button_html)) {
|
|
||||||
if (trial.button_html.length == trial.choices.length) {
|
|
||||||
buttons = trial.button_html;
|
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
for (var i = 0; i < trial.choices.length; i++) {
|
||||||
"Error in html-button-response plugin. The length of the button_html array does not equal the length of the choices array"
|
buttons.push(trial.button_html);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
} else {
|
html += '<div id="jspsych-html-button-response-btngroup">';
|
||||||
for (var i = 0; i < trial.choices.length; i++) {
|
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 +=
|
||||||
|
'<div class="jspsych-html-button-response-button" style="display: inline-block; margin:' +
|
||||||
|
trial.margin_vertical +
|
||||||
|
" " +
|
||||||
|
trial.margin_horizontal +
|
||||||
|
'" id="jspsych-html-button-response-button-' +
|
||||||
|
i +
|
||||||
|
'" data-choice="' +
|
||||||
|
i +
|
||||||
|
'">' +
|
||||||
|
str +
|
||||||
|
"</div>";
|
||||||
}
|
}
|
||||||
}
|
html += "</div>";
|
||||||
html += '<div id="jspsych-html-button-response-btngroup">';
|
|
||||||
for (var i = 0; i < trial.choices.length; i++) {
|
|
||||||
var str = buttons[i].replace(/%choice%/g, trial.choices[i]);
|
|
||||||
html +=
|
|
||||||
'<div class="jspsych-html-button-response-button" style="display: inline-block; margin:' +
|
|
||||||
trial.margin_vertical +
|
|
||||||
" " +
|
|
||||||
trial.margin_horizontal +
|
|
||||||
'" id="jspsych-html-button-response-button-' +
|
|
||||||
i +
|
|
||||||
'" data-choice="' +
|
|
||||||
i +
|
|
||||||
'">' +
|
|
||||||
str +
|
|
||||||
"</div>";
|
|
||||||
}
|
|
||||||
html += "</div>";
|
|
||||||
|
|
||||||
//show prompt if there is one
|
//show prompt if there is one
|
||||||
if (trial.prompt !== null) {
|
if (trial.prompt !== null) {
|
||||||
html += trial.prompt;
|
html += trial.prompt;
|
||||||
}
|
}
|
||||||
display_element.innerHTML = html;
|
display_element.innerHTML = html;
|
||||||
|
|
||||||
// start time
|
// start time
|
||||||
var start_time = performance.now();
|
var start_time = performance.now();
|
||||||
|
|
||||||
// add event listeners to buttons
|
// add event listeners to buttons
|
||||||
for (var i = 0; i < trial.choices.length; i++) {
|
for (var i = 0; i < trial.choices.length; i++) {
|
||||||
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;
|
||||||
after_response(choice);
|
var choice = btn_el.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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (trial.response_ends_trial) {
|
// store response
|
||||||
end_trial();
|
var response = {
|
||||||
}
|
rt: null,
|
||||||
}
|
button: null,
|
||||||
|
|
||||||
// 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
|
// function to end trial when it is time
|
||||||
display_element.innerHTML = "";
|
const end_trial = () => {
|
||||||
|
// kill any remaining setTimeout handlers
|
||||||
|
this.jsPsych.pluginAPI.clearAllTimeouts();
|
||||||
|
|
||||||
// move on to the next trial
|
// gather the data to store for the trial
|
||||||
jsPsych.finishTrial(trial_data);
|
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<HTMLElement>(
|
||||||
|
"#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
|
export default HtmlButtonResponsePlugin;
|
||||||
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;
|
|
||||||
|
@ -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,141 +19,161 @@ 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;
|
||||||
var html = `
|
|
||||||
<div id='webgazer-calibrate-container' style='position: relative; width:100vw; height:100vh'>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
display_element.innerHTML = html;
|
/**
|
||||||
|
* jspsych-webgazer-calibrate
|
||||||
|
* Josh de Leeuw
|
||||||
|
**/
|
||||||
|
class WebgazerCalibratePlugin implements JsPsychPlugin<Info> {
|
||||||
|
static info = info;
|
||||||
|
|
||||||
var wg_container = display_element.querySelector("#webgazer-calibrate-container");
|
constructor(private jsPsych: JsPsych) {}
|
||||||
|
|
||||||
var reps_completed = 0;
|
trial(display_element: HTMLElement, trial: TrialType<Info>) {
|
||||||
var points_completed = -1;
|
var html = `
|
||||||
var cal_points = null;
|
<div id='webgazer-calibrate-container' style='position: relative; width:100vw; height:100vh'>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
calibrate();
|
display_element.innerHTML = html;
|
||||||
|
|
||||||
function calibrate() {
|
var wg_container = display_element.querySelector("#webgazer-calibrate-container");
|
||||||
jsPsych.extensions["webgazer"].resume();
|
|
||||||
if (trial.calibration_mode == "click") {
|
|
||||||
jsPsych.extensions["webgazer"].startMouseCalibration();
|
|
||||||
}
|
|
||||||
next_calibration_round();
|
|
||||||
}
|
|
||||||
|
|
||||||
function next_calibration_round() {
|
var reps_completed = 0;
|
||||||
if (trial.randomize_calibration_order) {
|
var points_completed = -1;
|
||||||
cal_points = jsPsych.randomization.shuffle(trial.calibration_points);
|
var cal_points = null;
|
||||||
} else {
|
|
||||||
cal_points = trial.calibration_points;
|
|
||||||
}
|
|
||||||
points_completed = -1;
|
|
||||||
next_calibration_point();
|
|
||||||
}
|
|
||||||
|
|
||||||
function next_calibration_point() {
|
const next_calibration_round = () => {
|
||||||
points_completed++;
|
if (trial.randomize_calibration_order) {
|
||||||
if (points_completed == cal_points.length) {
|
cal_points = this.jsPsych.randomization.shuffle(trial.calibration_points);
|
||||||
reps_completed++;
|
|
||||||
if (reps_completed == trial.repetitions_per_point) {
|
|
||||||
calibration_done();
|
|
||||||
} else {
|
} else {
|
||||||
next_calibration_round();
|
cal_points = trial.calibration_points;
|
||||||
}
|
}
|
||||||
} else {
|
points_completed = -1;
|
||||||
var pt = cal_points[points_completed];
|
next_calibration_point();
|
||||||
calibration_display_gaze_only(pt);
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calibration_display_gaze_only(pt) {
|
const calibrate = () => {
|
||||||
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>`;
|
this.jsPsych.extensions["webgazer"].resume();
|
||||||
wg_container.innerHTML = pt_html;
|
if (trial.calibration_mode == "click") {
|
||||||
|
this.jsPsych.extensions["webgazer"].startMouseCalibration();
|
||||||
|
}
|
||||||
|
next_calibration_round();
|
||||||
|
};
|
||||||
|
|
||||||
var pt_dom = wg_container.querySelector("#calibration-point");
|
const next_calibration_point = () => {
|
||||||
|
points_completed++;
|
||||||
if (trial.calibration_mode == "click") {
|
if (points_completed == cal_points.length) {
|
||||||
pt_dom.style.cursor = "pointer";
|
reps_completed++;
|
||||||
pt_dom.addEventListener("click", function () {
|
if (reps_completed == trial.repetitions_per_point) {
|
||||||
next_calibration_point();
|
calibration_done();
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
} else {
|
} 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 = `<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;
|
||||||
|
|
||||||
|
var pt_dom = wg_container.querySelector<HTMLElement>("#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() {
|
export default WebgazerCalibratePlugin;
|
||||||
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;
|
|
||||||
|
@ -1,144 +1,151 @@
|
|||||||
/**
|
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;
|
||||||
var start_time = performance.now();
|
|
||||||
var load_time;
|
|
||||||
|
|
||||||
if (!jsPsych.extensions.webgazer.isInitialized()) {
|
/**
|
||||||
jsPsych.extensions.webgazer
|
* jspsych-webgazer-init-camera
|
||||||
.start()
|
* Josh de Leeuw
|
||||||
.then(function () {
|
**/
|
||||||
showTrial();
|
class WebgazerInitCameraPlugin implements JsPsychPlugin<Info> {
|
||||||
})
|
static info = info;
|
||||||
.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() {
|
constructor(private jsPsych: JsPsych) {}
|
||||||
load_time = Math.round(performance.now() - start_time);
|
|
||||||
|
|
||||||
var style = `
|
trial(display_element: HTMLElement, trial: TrialType<Info>) {
|
||||||
<style id="webgazer-center-style">
|
var start_time = performance.now();
|
||||||
#webgazerVideoContainer { top: 20px !important; left: calc(50% - 160px) !important;}
|
var load_time: number;
|
||||||
</style>
|
|
||||||
`;
|
|
||||||
document.querySelector("head").insertAdjacentHTML("beforeend", style);
|
|
||||||
|
|
||||||
var html = `
|
// function to end trial when it is time
|
||||||
<div id='webgazer-init-container' style='position: relative; width:100vw; height:100vh'>
|
const end_trial = () => {
|
||||||
</div>`;
|
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();
|
// gather the data to store for the trial
|
||||||
jsPsych.extensions["webgazer"].resume();
|
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 = `
|
document.querySelector("#webgazer-center-style").remove();
|
||||||
<div style='position: absolute; top: max(260px, 40%); left: calc(50% - 400px); width:800px;'>
|
|
||||||
${trial.instructions}
|
|
||||||
<button id='jspsych-wg-cont' class='jspsych-btn' disabled>${trial.button_text}</button>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
if (is_face_detect_green()) {
|
// move on to the next trial
|
||||||
(document.querySelector("#jspsych-wg-cont") as HTMLButtonElement).disabled = false;
|
this.jsPsych.finishTrial(trial_data);
|
||||||
} 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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// clear the display
|
const showTrial = () => {
|
||||||
display_element.innerHTML = "";
|
load_time = Math.round(performance.now() - start_time);
|
||||||
|
|
||||||
document.querySelector("#webgazer-center-style").remove();
|
var style = `
|
||||||
|
<style id="webgazer-center-style">
|
||||||
|
#webgazerVideoContainer { top: 20px !important; left: calc(50% - 160px) !important;}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
document.querySelector("head").insertAdjacentHTML("beforeend", style);
|
||||||
|
|
||||||
// move on to the next trial
|
var html = `
|
||||||
jsPsych.finishTrial(trial_data);
|
<div id='webgazer-init-container' style='position: relative; width:100vw; height:100vh'>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div style='position: absolute; top: max(260px, 40%); left: calc(50% - 400px); width:800px;'>
|
||||||
|
${trial.instructions}
|
||||||
|
<button id='jspsych-wg-cont' class='jspsych-btn' disabled>${trial.button_text}</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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 = `<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() {
|
||||||
|
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;
|
||||||
|
@ -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,346 +20,366 @@ 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;
|
||||||
var trial_data = <any>{};
|
|
||||||
trial_data.raw_gaze = [];
|
|
||||||
trial_data.percent_in_roi = [];
|
|
||||||
trial_data.average_offset = [];
|
|
||||||
trial_data.validation_points = null;
|
|
||||||
|
|
||||||
var html = `
|
/**
|
||||||
<div id='webgazer-validate-container' style='position: relative; width:100vw; height:100vh; overflow: hidden;'>
|
* jspsych-webgazer-validate
|
||||||
</div>`;
|
* Josh de Leeuw
|
||||||
|
**/
|
||||||
|
class WebgazerValidatePlugin implements JsPsychPlugin<Info> {
|
||||||
|
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<Info>) {
|
||||||
|
var trial_data = <any>{};
|
||||||
|
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 `<div class="validation-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:${x}%; top:${y}%;"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawValidationPoint_CenterOffsetMode(x, y) {
|
|
||||||
return `<div class="validation-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:calc(50% - ${
|
|
||||||
trial.point_size / 2
|
|
||||||
}px + ${x}px); top:calc(50% - ${trial.point_size / 2}px + ${y}px);"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @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 = `
|
var html = `
|
||||||
<div class="validation-centroid" style="width:${r * 2}px; height:${
|
<div id='webgazer-validate-container' style='position: relative; width:100vw; height:100vh; overflow: hidden;'>
|
||||||
r * 2
|
</div>`;
|
||||||
}px; border: 2px dotted #ccc; border-radius: ${r}px; background-color: transparent; position: absolute; left:calc(${target_x}% + ${
|
|
||||||
dx - r
|
|
||||||
}px); top:calc(${target_y}% + ${dy - r}px);"></div>
|
|
||||||
`;
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawCircle_CenterOffsetMode(target_x, target_y, dx, dy, r) {
|
display_element.innerHTML = html;
|
||||||
var html = `
|
|
||||||
<div class="validation-centroid" style="width:${r * 2}px; height:${
|
|
||||||
r * 2
|
|
||||||
}px; border: 2px dotted #ccc; border-radius: ${r}px; background-color: transparent; position: absolute; left:calc(50% + ${target_x}px + ${
|
|
||||||
dx - r
|
|
||||||
}px); top:calc(50% + ${target_y}px + ${dy - r}px);"></div>
|
|
||||||
`;
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-expect-error
|
var wg_container = display_element.querySelector("#webgazer-validate-container");
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawRawDataPoint_PercentMode(target_x, target_y, dx, dy) {
|
var points_completed = -1;
|
||||||
var color = Math.sqrt(dx * dx + dy * dy) <= trial.roi_radius ? "#afa" : "#faa";
|
var val_points = null;
|
||||||
return `<div class="raw-data-point" style="width:5px; height:5px; border-radius:5px; background-color: ${color}; opacity:0.8; position: absolute; left:calc(${target_x}% + ${
|
var start = performance.now();
|
||||||
dx - 2
|
|
||||||
}px); top:calc(${target_y}% + ${dy - 2}px);"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawRawDataPoint_CenterOffsetMode(target_x, target_y, dx, dy) {
|
// function to end trial when it is time
|
||||||
var color = Math.sqrt(dx * dx + dy * dy) <= trial.roi_radius ? "#afa" : "#faa";
|
const end_trial = () => {
|
||||||
return `<div class="raw-data-point" style="width:5px; height:5px; border-radius:5px; background-color: ${color}; opacity:0.8; position: absolute; left:calc(50% + ${target_x}px + ${
|
this.jsPsych.extensions.webgazer.stopSampleInterval();
|
||||||
dx - 2
|
|
||||||
}px); top:calc(50% + ${target_y}px + ${dy - 2}px);"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function median(arr) {
|
// kill any remaining setTimeout handlers
|
||||||
var mid = Math.floor(arr.length / 2);
|
this.jsPsych.pluginAPI.clearAllTimeouts();
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateGazeCentroid(gazeData) {
|
// clear the display
|
||||||
var x_diff_m = gazeData.reduce(function (accumulator, currentValue, index) {
|
display_element.innerHTML = "";
|
||||||
accumulator += currentValue.dx;
|
|
||||||
if (index == gazeData.length - 1) {
|
|
||||||
return accumulator / gazeData.length;
|
|
||||||
} else {
|
|
||||||
return accumulator;
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
var y_diff_m = gazeData.reduce(function (accumulator, currentValue, index) {
|
// move on to the next trial
|
||||||
accumulator += currentValue.dy;
|
this.jsPsych.finishTrial(trial_data);
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function calculatePercentInROI(gazeData) {
|
const validation_display = (pt) => {
|
||||||
var distances = gazeData.map(function (p) {
|
var pt_html = drawValidationPoint(pt[0], pt[1]);
|
||||||
return Math.sqrt(Math.pow(p.dx, 2) + Math.pow(p.dy, 2));
|
wg_container.innerHTML = pt_html;
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateSampleRate(gazeData) {
|
var pt_dom = wg_container.querySelector(".validation-point");
|
||||||
var mean_diff = [];
|
|
||||||
for (var i = 0; i < gazeData.length; i++) {
|
var br = pt_dom.getBoundingClientRect();
|
||||||
if (gazeData[i].length > 1) {
|
var x = br.left + br.width / 2;
|
||||||
var t_diff = [];
|
var y = br.top + br.height / 2;
|
||||||
for (var j = 1; j < gazeData[i].length; j++) {
|
|
||||||
t_diff.push(gazeData[i][j].t - gazeData[i][j - 1].t);
|
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;
|
requestAnimationFrame(function watch_dot() {
|
||||||
}, 0) / t_diff.length
|
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() {
|
const validate = () => {
|
||||||
trial_data.samples_per_sec = calculateSampleRate(trial_data.raw_gaze).toFixed(2);
|
if (trial.randomize_validation_order) {
|
||||||
for (var i = 0; i < trial.validation_points.length; i++) {
|
val_points = this.jsPsych.randomization.shuffle(trial.validation_points);
|
||||||
trial_data.percent_in_roi[i] = calculatePercentInROI(trial_data.raw_gaze[i]);
|
} else {
|
||||||
trial_data.average_offset[i] = calculateGazeCentroid(trial_data.raw_gaze[i]);
|
val_points = trial.validation_points;
|
||||||
}
|
}
|
||||||
if (trial.show_validation_data) {
|
trial_data.validation_points = val_points;
|
||||||
show_validation_data();
|
points_completed = -1;
|
||||||
} else {
|
//jsPsych.extensions['webgazer'].resume();
|
||||||
end_trial();
|
this.jsPsych.extensions.webgazer.startSampleInterval();
|
||||||
}
|
//jsPsych.extensions.webgazer.showPredictions();
|
||||||
}
|
next_validation_point();
|
||||||
|
};
|
||||||
|
|
||||||
function show_validation_data() {
|
const show_validation_data = () => {
|
||||||
var html = "";
|
var html = "";
|
||||||
for (var i = 0; i < trial.validation_points.length; i++) {
|
for (var i = 0; i < trial.validation_points.length; i++) {
|
||||||
html += drawValidationPoint(trial.validation_points[i][0], trial.validation_points[i][1]);
|
html += drawValidationPoint(trial.validation_points[i][0], trial.validation_points[i][1]);
|
||||||
html += drawCircle(
|
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][0],
|
||||||
trial.validation_points[i][1],
|
trial.validation_points[i][1],
|
||||||
trial_data.raw_gaze[i][j].dx,
|
0,
|
||||||
trial_data.raw_gaze[i][j].dy
|
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
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html +=
|
function drawValidationPoint_PercentMode(x, y) {
|
||||||
'<button id="cont" style="position:absolute; top: 50%; left:calc(50% - 50px); width: 100px;" class="jspsych-btn">Continue</btn>';
|
return `<div class="validation-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:${x}%; top:${y}%;"></div>`;
|
||||||
wg_container.innerHTML = html;
|
}
|
||||||
wg_container.querySelector("#cont").addEventListener("click", function () {
|
|
||||||
jsPsych.extensions.webgazer.pause();
|
function drawValidationPoint_CenterOffsetMode(x, y) {
|
||||||
end_trial();
|
return `<div class="validation-point" style="width:${trial.point_size}px; height:${
|
||||||
});
|
trial.point_size
|
||||||
// turn on webgazer's loop
|
}px; border-radius:${
|
||||||
jsPsych.extensions.webgazer.showPredictions();
|
trial.point_size
|
||||||
jsPsych.extensions.webgazer.stopSampleInterval();
|
}px; border: 1px solid #000; background-color: #333; position: absolute; left:calc(50% - ${
|
||||||
jsPsych.extensions.webgazer.resume();
|
trial.point_size / 2
|
||||||
|
}px + ${x}px); top:calc(50% - ${trial.point_size / 2}px + ${y}px);"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @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 = `
|
||||||
|
<div class="validation-centroid" style="width:${r * 2}px; height:${
|
||||||
|
r * 2
|
||||||
|
}px; border: 2px dotted #ccc; border-radius: ${r}px; background-color: transparent; position: absolute; left:calc(${target_x}% + ${
|
||||||
|
dx - r
|
||||||
|
}px); top:calc(${target_y}% + ${dy - r}px);"></div>
|
||||||
|
`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCircle_CenterOffsetMode(target_x, target_y, dx, dy, r) {
|
||||||
|
var html = `
|
||||||
|
<div class="validation-centroid" style="width:${r * 2}px; height:${
|
||||||
|
r * 2
|
||||||
|
}px; border: 2px dotted #ccc; border-radius: ${r}px; background-color: transparent; position: absolute; left:calc(50% + ${target_x}px + ${
|
||||||
|
dx - r
|
||||||
|
}px); top:calc(50% + ${target_y}px + ${dy - r}px);"></div>
|
||||||
|
`;
|
||||||
|
return 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRawDataPoint_PercentMode(target_x, target_y, dx, dy) {
|
||||||
|
var color = Math.sqrt(dx * dx + dy * dy) <= trial.roi_radius ? "#afa" : "#faa";
|
||||||
|
return `<div class="raw-data-point" style="width:5px; height:5px; border-radius:5px; background-color: ${color}; opacity:0.8; position: absolute; left:calc(${target_x}% + ${
|
||||||
|
dx - 2
|
||||||
|
}px); top:calc(${target_y}% + ${dy - 2}px);"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRawDataPoint_CenterOffsetMode(target_x, target_y, dx, dy) {
|
||||||
|
var color = Math.sqrt(dx * dx + dy * dy) <= trial.roi_radius ? "#afa" : "#faa";
|
||||||
|
return `<div class="raw-data-point" style="width:5px; height:5px; border-radius:5px; background-color: ${color}; opacity:0.8; position: absolute; left:calc(50% + ${target_x}px + ${
|
||||||
|
dx - 2
|
||||||
|
}px); top:calc(50% + ${target_y}px + ${dy - 2}px);"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
mean_diff.push(
|
||||||
|
t_diff.reduce(function (a, b) {
|
||||||
|
return a + b;
|
||||||
|
}, 0) / t_diff.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mean_diff.length > 0) {
|
||||||
|
return (
|
||||||
|
1000 /
|
||||||
|
(mean_diff.reduce(function (a, b) {
|
||||||
|
return a + b;
|
||||||
|
}, 0) /
|
||||||
|
mean_diff.length)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// function to end trial when it is time
|
export default WebgazerValidatePlugin;
|
||||||
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;
|
|
||||||
|
Loading…
Reference in New Issue
Block a user