mirror of
https://github.com/jspsych/jsPsych.git
synced 2025-05-10 19:20:55 +00:00
Merge branch 'main' into plugin-sketchpad
This commit is contained in:
commit
c91cc4fd32
5
.changeset/quick-mangos-vanish.md
Normal file
5
.changeset/quick-mangos-vanish.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jspsych/test-utils": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added `mouseMove()`, `mouseDown()`, and `mouseUp()` utility functions to dispatch mouse events with location relative to a target element.
|
5
.changeset/ten-owls-talk.md
Normal file
5
.changeset/ten-owls-talk.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jspsych/extension-mouse-tracking": major
|
||||||
|
---
|
||||||
|
|
||||||
|
Created an extension that enables mouse tracking. The extension records the coordinates and time of mousemove, mousedown, and mouseup events, as well as optionally recording the coordinates of objects on the screen to enable mapping of mouse events onto screen objects.
|
99
docs/demos/jspsych-extension-mouse-tracking-demo1.html
Normal file
99
docs/demos/jspsych-extension-mouse-tracking-demo1.html
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="https://unpkg.com/jspsych@7.0.0"></script>
|
||||||
|
<script src="https://unpkg.com/@jspsych/plugin-html-button-response@1.0.0"></script>
|
||||||
|
<!--<script src="../../packages/extension-mouse-tracking/dist/index.browser.js"></script>-->
|
||||||
|
<script src="https://unpkg.com/@jspsych/extension-mouse-tracking@1.0.0"></script>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/jspsych@7.0.0/css/jspsych.css">
|
||||||
|
<style>
|
||||||
|
.jspsych-btn {margin-bottom: 10px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
var jsPsych = initJsPsych({
|
||||||
|
extensions: [
|
||||||
|
{ type: jsPsychExtensionMouseTracking, params: {minimum_sample_time: 0} }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
var start = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '',
|
||||||
|
choices: ['Run demo']
|
||||||
|
};
|
||||||
|
|
||||||
|
var show_data = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: function() {
|
||||||
|
var trial_data = jsPsych.data.get().filter({task: 'draw'}).last(1).values();
|
||||||
|
var trial_json = JSON.stringify(trial_data, null, 2);
|
||||||
|
return `<p style="margin-bottom:0px;"><strong>Trial data:</strong></p>
|
||||||
|
<pre style="margin-top:0px;text-align:left;">${trial_json}</pre>`;
|
||||||
|
},
|
||||||
|
choices: ['Repeat demo']
|
||||||
|
};
|
||||||
|
|
||||||
|
var trial = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '<div id="target" style="width:250px; height: 250px; background-color: #333; margin: auto;"></div>',
|
||||||
|
choices: ['Done'],
|
||||||
|
prompt: "<p>Move your mouse around inside the square.</p>",
|
||||||
|
extensions: [
|
||||||
|
{type: jsPsychExtensionMouseTracking, params: {targets: ['#target']}}
|
||||||
|
],
|
||||||
|
data: {
|
||||||
|
task: 'draw'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var replay = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '<div id="target" style="width:250px; height: 250px; background-color: #333; margin: auto; position: relative;"></div>',
|
||||||
|
choices: ['Done'],
|
||||||
|
prompt: "<p>Here's the recording of your mouse movements</p>",
|
||||||
|
on_load: function(){
|
||||||
|
var mouseMovements = jsPsych.data.get().last(1).values()[0].mouse_tracking_data;
|
||||||
|
var targetRect = jsPsych.data.get().last(1).values()[0].mouse_tracking_targets['#target'];
|
||||||
|
|
||||||
|
var startTime = performance.now();
|
||||||
|
|
||||||
|
function draw_frame() {
|
||||||
|
var timeElapsed = performance.now() - startTime;
|
||||||
|
var points = mouseMovements.filter((x) => x.t <= timeElapsed);
|
||||||
|
var html = ``;
|
||||||
|
for(var p of points){
|
||||||
|
html += `<div style="width: 3px; height: 3px; background-color: blue; position: absolute; top: ${p.y - 1 - targetRect.top}px; left: ${p.x - 1 - targetRect.left}px;"></div>`
|
||||||
|
}
|
||||||
|
document.querySelector('#target').innerHTML = html;
|
||||||
|
if(points.length < mouseMovements.length) {
|
||||||
|
requestAnimationFrame(draw_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(draw_frame);
|
||||||
|
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
task: 'replay'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var trial_loop = {
|
||||||
|
timeline: [trial, replay, show_data],
|
||||||
|
loop_function: function() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof jsPsych !== "undefined") {
|
||||||
|
jsPsych.run([start, trial_loop]);
|
||||||
|
} else {
|
||||||
|
document.body.innerHTML = '<div style="text-align:center; margin-top:50%; transform:translate(0,-50%);">You must be online to view the plugin demo.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</html>
|
@ -9,4 +9,5 @@ For an overview of what extensions are and how they work, see our [extensions ov
|
|||||||
|
|
||||||
Extension | Description
|
Extension | Description
|
||||||
------ | -----------
|
------ | -----------
|
||||||
[jspsych‑ext‑webgazer](../extensions/webgazer.md) | Enables eye tracking using the [WebGazer](https://webgazer.cs.brown.edu/) library.
|
[mouse‑tracking](../extensions/mouse-tracking.md) | Enables tracking of mouse events and recording location of objects on screen.
|
||||||
|
[webgazer](../extensions/webgazer.md) | Enables eye tracking using the [WebGazer](https://webgazer.cs.brown.edu/) library.
|
106
docs/extensions/mouse-tracking.md
Normal file
106
docs/extensions/mouse-tracking.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# mouse-tracking
|
||||||
|
|
||||||
|
This extension supports mouse tracking.
|
||||||
|
Specifically, it can record the `x` `y` coordinates and time of [mousemove events](https://developer.mozilla.org/en-US/docs/Web/API/Element/mousemove_event), [mousedown events](https://developer.mozilla.org/en-US/docs/Web/API/Element/mousedown_event), and [mouseup events](https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseup_event).
|
||||||
|
It also allows recording of the [bounding rectangle](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) of elements on the screen to support the calculation of mouse events relative to different elements.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
### Initialization Parameters
|
||||||
|
|
||||||
|
Initialization parameters can be set when calling `initJsPsych()`
|
||||||
|
|
||||||
|
```js
|
||||||
|
initJsPsych({
|
||||||
|
extensions: [
|
||||||
|
{type: jsPsychExtensionMouseTracking, params: {...}}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter | Type | Default Value | Description
|
||||||
|
----------|------|---------------|------------
|
||||||
|
minimum_sample_time | number | 0 | The minimum time between samples for `mousemove` events in milliseconds. If `mousemove` events occur more rapidly than this limit, they will not be recorded. Use this if you want to keep the data files smaller and don't need high resolution tracking data. The default value of 0 means that all events will be recorded.
|
||||||
|
|
||||||
|
### Trial Parameters
|
||||||
|
|
||||||
|
Trial parameters can be set when adding the extension to a trial object.
|
||||||
|
|
||||||
|
```js
|
||||||
|
var trial = {
|
||||||
|
type: jsPsych...,
|
||||||
|
extensions: [
|
||||||
|
{type: jsPsychExtensionWebgazer, params: {...}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Parameter | Type | Default Value | Description
|
||||||
|
----------|------|---------------|------------
|
||||||
|
targets | array | [] | A list of elements on the page that you would like to record the coordinates of for comparison with the WebGazer data. Each entry in the array should be a valid [CSS selector string](https://www.w3schools.com/cssref/css_selectors.asp) that identifies the element. The selector string should be valid for exactly one element on the page. If the selector is valid for more than one element then only the first matching element will be recorded.
|
||||||
|
events | array | ['mousemove'] | A list of events to track. Can include 'mousemove', 'mousedown', and 'mouseup'.
|
||||||
|
|
||||||
|
## Data Generated
|
||||||
|
|
||||||
|
Name | Type | Value
|
||||||
|
-----|------|------
|
||||||
|
mouse_tracking_data | array | An array of objects containing mouse movement data for the trial. Each object has an `x`, a `y`, a `t`, and an `event` property. The `x` and `y` properties specify the mouse coordinates in pixels relative to the top left corner of the viewport and `t` specifies the time in milliseconds since the start of the trial. The `event` will be either 'mousemove', 'mousedown', or 'mouseup' depending on which event was generated.
|
||||||
|
mouse_tracking_targets | object | An object contain the pixel coordinates of elements on the screen specified by the `.targets` parameter. Each key in this object will be a `selector` property, containing the CSS selector string used to find the element. The object corresponding to each key will contain `x` and `y` properties specifying the top-left corner of the object, `width` and `height` values, plus `top`, `bottom`, `left`, and `right` parameters which specify the [bounding rectangle](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) of the element.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
???+ example "Record mouse movement data and play it back"
|
||||||
|
=== "Code"
|
||||||
|
```javascript
|
||||||
|
var trial = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '<div id="target" style="width:250px; height: 250px; background-color: #333; margin: auto;"></div>',
|
||||||
|
choices: ['Done'],
|
||||||
|
prompt: "<p>Move your mouse around inside the square.</p>",
|
||||||
|
extensions: [
|
||||||
|
{type: jsPsychExtensionMouseTracking, params: {targets: ['#target']}}
|
||||||
|
],
|
||||||
|
data: {
|
||||||
|
task: 'draw'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var replay = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '<div id="target" style="width:250px; height: 250px; background-color: #333; margin: auto; position: relative;"></div>',
|
||||||
|
choices: ['Done'],
|
||||||
|
prompt: "<p>Here's the recording of your mouse movements</p>",
|
||||||
|
on_load: function(){
|
||||||
|
var mouseMovements = jsPsych.data.get().last(1).values()[0].mouse_tracking_data;
|
||||||
|
var targetRect = jsPsych.data.get().last(1).values()[0].mouse_tracking_targets['#target'];
|
||||||
|
|
||||||
|
var startTime = performance.now();
|
||||||
|
|
||||||
|
function draw_frame() {
|
||||||
|
var timeElapsed = performance.now() - startTime;
|
||||||
|
var points = mouseMovements.filter((x) => x.t <= timeElapsed);
|
||||||
|
var html = ``;
|
||||||
|
for(var p of points){
|
||||||
|
html += `<div style="width: 3px; height: 3px; background-color: blue; position: absolute; top: ${p.y - 1 - targetRect.top}px; left: ${p.x - 1 - targetRect.left}px;"></div>`
|
||||||
|
}
|
||||||
|
document.querySelector('#target').innerHTML = html;
|
||||||
|
if(points.length < mouseMovements.length) {
|
||||||
|
requestAnimationFrame(draw_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(draw_frame);
|
||||||
|
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
task: 'replay'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Demo"
|
||||||
|
<div style="text-align:center;">
|
||||||
|
<iframe src="../../demos/jspsych-extension-mouse-tracking-demo1.html" width="90%;" height="500px;" frameBorder="0"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a target="_blank" rel="noopener noreferrer" href="../../demos/jspsych-extension-mouse-tracking-demo1.html">Open demo in new tab</a>
|
98
examples/jspsych-extension-mouse-tracking.html
Normal file
98
examples/jspsych-extension-mouse-tracking.html
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script src="../packages/jspsych/dist/index.browser.js"></script>
|
||||||
|
<script src="../packages/plugin-html-button-response/dist/index.browser.js"></script>
|
||||||
|
<script src="../packages/extension-mouse-tracking/dist/index.browser.js"></script>
|
||||||
|
<link rel="stylesheet" href="../packages/jspsych/css/jspsych.css">
|
||||||
|
<style>
|
||||||
|
.jspsych-btn {margin-bottom: 10px;}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
var jsPsych = initJsPsych({
|
||||||
|
extensions: [
|
||||||
|
{ type: jsPsychExtensionMouseTracking, params: {minimum_sample_time: 0} }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
var start = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '',
|
||||||
|
choices: ['Run demo']
|
||||||
|
};
|
||||||
|
|
||||||
|
var show_data = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: function() {
|
||||||
|
var trial_data = jsPsych.data.get().filter({task: 'draw'}).last(1).values();
|
||||||
|
var trial_json = JSON.stringify(trial_data, null, 2);
|
||||||
|
return `<p style="margin-bottom:0px;"><strong>Trial data:</strong></p>
|
||||||
|
<pre style="margin-top:0px;text-align:left;">${trial_json}</pre>`;
|
||||||
|
},
|
||||||
|
choices: ['Repeat demo']
|
||||||
|
};
|
||||||
|
|
||||||
|
var trial = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '<div id="target" style="width:250px; height: 250px; background-color: #333; margin: auto;"></div>',
|
||||||
|
choices: ['Done'],
|
||||||
|
prompt: "<p>Move your mouse around inside the square.</p>",
|
||||||
|
extensions: [
|
||||||
|
{type: jsPsychExtensionMouseTracking, params: {targets: ['#target']}}
|
||||||
|
],
|
||||||
|
data: {
|
||||||
|
task: 'draw'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var replay = {
|
||||||
|
type: jsPsychHtmlButtonResponse,
|
||||||
|
stimulus: '<div id="target" style="width:250px; height: 250px; background-color: #333; margin: auto; position: relative;"></div>',
|
||||||
|
choices: ['Done'],
|
||||||
|
prompt: "<p>Here's the recording of your mouse movements</p>",
|
||||||
|
on_load: function(){
|
||||||
|
var mouseMovements = jsPsych.data.get().last(1).values()[0].mouse_tracking_data;
|
||||||
|
var targetRect = jsPsych.data.get().last(1).values()[0].mouse_tracking_targets['#target'];
|
||||||
|
|
||||||
|
var startTime = performance.now();
|
||||||
|
|
||||||
|
function draw_frame() {
|
||||||
|
var timeElapsed = performance.now() - startTime;
|
||||||
|
var points = mouseMovements.filter((x) => x.t <= timeElapsed);
|
||||||
|
var html = ``;
|
||||||
|
for(var p of points){
|
||||||
|
html += `<div style="width: 3px; height: 3px; background-color: blue; position: absolute; top: ${p.y - 1 - targetRect.top}px; left: ${p.x - 1 - targetRect.left}px;"></div>`
|
||||||
|
}
|
||||||
|
document.querySelector('#target').innerHTML = html;
|
||||||
|
if(points.length < mouseMovements.length) {
|
||||||
|
requestAnimationFrame(draw_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(draw_frame);
|
||||||
|
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
task: 'replay'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var trial_loop = {
|
||||||
|
timeline: [trial, replay, show_data],
|
||||||
|
loop_function: function() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof jsPsych !== "undefined") {
|
||||||
|
jsPsych.run([start, trial_loop]);
|
||||||
|
} else {
|
||||||
|
document.body.innerHTML = '<div style="text-align:center; margin-top:50%; transform:translate(0,-50%);">You must be online to view the plugin demo.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</html>
|
@ -118,6 +118,7 @@ nav:
|
|||||||
- 'webgazer-validate': 'plugins/webgazer-validate.md'
|
- 'webgazer-validate': 'plugins/webgazer-validate.md'
|
||||||
- Extensions:
|
- Extensions:
|
||||||
- 'List of Extensions': 'extensions/list-of-extensions.md'
|
- 'List of Extensions': 'extensions/list-of-extensions.md'
|
||||||
|
- 'mouse-tracking': 'extensions/mouse-tracking.md'
|
||||||
- 'webgazer': 'extensions/webgazer.md'
|
- 'webgazer': 'extensions/webgazer.md'
|
||||||
- Developers:
|
- Developers:
|
||||||
- 'Configuration': 'developers/configuration.md'
|
- 'Configuration': 'developers/configuration.md'
|
||||||
|
23
package-lock.json
generated
23
package-lock.json
generated
@ -2430,6 +2430,10 @@
|
|||||||
"resolved": "packages/config",
|
"resolved": "packages/config",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@jspsych/extension-mouse-tracking": {
|
||||||
|
"resolved": "packages/extension-mouse-tracking",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@jspsych/extension-webgazer": {
|
"node_modules/@jspsych/extension-webgazer": {
|
||||||
"resolved": "packages/extension-webgazer",
|
"resolved": "packages/extension-webgazer",
|
||||||
"link": true
|
"link": true
|
||||||
@ -14544,6 +14548,18 @@
|
|||||||
"node": ">=4.2.0"
|
"node": ">=4.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/extension-mouse-tracking": {
|
||||||
|
"name": "@jspsych/extension-mouse-tracking",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@jspsych/config": "^1.0.0",
|
||||||
|
"@jspsych/test-utils": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"jspsych": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/extension-webgazer": {
|
"packages/extension-webgazer": {
|
||||||
"name": "@jspsych/extension-webgazer",
|
"name": "@jspsych/extension-webgazer",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@ -16923,6 +16939,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@jspsych/extension-mouse-tracking": {
|
||||||
|
"version": "file:packages/extension-mouse-tracking",
|
||||||
|
"requires": {
|
||||||
|
"@jspsych/config": "^1.0.0",
|
||||||
|
"@jspsych/test-utils": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@jspsych/extension-webgazer": {
|
"@jspsych/extension-webgazer": {
|
||||||
"version": "file:packages/extension-webgazer",
|
"version": "file:packages/extension-webgazer",
|
||||||
"requires": {
|
"requires": {
|
||||||
|
1
packages/extension-mouse-tracking/jest.config.cjs
Normal file
1
packages/extension-mouse-tracking/jest.config.cjs
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname);
|
43
packages/extension-mouse-tracking/package.json
Normal file
43
packages/extension-mouse-tracking/package.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@jspsych/extension-mouse-tracking",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "jsPsych extension for mouse tracking",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.cjs",
|
||||||
|
"exports": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist/index.cjs"
|
||||||
|
},
|
||||||
|
"typings": "dist/index.d.ts",
|
||||||
|
"unpkg": "dist/index.browser.min.js",
|
||||||
|
"files": [
|
||||||
|
"src",
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "npm test -- --watch",
|
||||||
|
"tsc": "tsc",
|
||||||
|
"build": "rollup --config",
|
||||||
|
"build:watch": "npm run build -- --watch"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/jspsych/jsPsych.git",
|
||||||
|
"directory": "packages/extension-mouse-tracking"
|
||||||
|
},
|
||||||
|
"author": "Josh de Leeuw",
|
||||||
|
"license": "MIT",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/jspsych/jsPsych/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://www.jspsych.org/latest/extensions/mouse-tracking",
|
||||||
|
"peerDependencies": {
|
||||||
|
"jspsych": ">=7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@jspsych/config": "^1.0.0",
|
||||||
|
"@jspsych/test-utils": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
3
packages/extension-mouse-tracking/rollup.config.mjs
Normal file
3
packages/extension-mouse-tracking/rollup.config.mjs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { makeRollupConfig } from "@jspsych/config/rollup";
|
||||||
|
|
||||||
|
export default makeRollupConfig("jsPsychExtensionMouseTracking");
|
276
packages/extension-mouse-tracking/src/index.spec.ts
Normal file
276
packages/extension-mouse-tracking/src/index.spec.ts
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
|
||||||
|
import { mouseDown, mouseMove, mouseUp, pressKey, startTimeline } from "@jspsych/test-utils";
|
||||||
|
import { initJsPsych } from "jspsych";
|
||||||
|
|
||||||
|
import MouseTrackingExtension from ".";
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
describe("Mouse Tracking Extension", () => {
|
||||||
|
test("adds mouse move data to trial", async () => {
|
||||||
|
const jsPsych = initJsPsych({
|
||||||
|
extensions: [{ type: MouseTrackingExtension }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{
|
||||||
|
type: htmlKeyboardResponse,
|
||||||
|
stimulus: "<div id='target' style='width:500px; height: 500px;'></div>",
|
||||||
|
extensions: [{ type: MouseTrackingExtension }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { displayElement, getHTML, getData, expectFinished } = await startTimeline(
|
||||||
|
timeline,
|
||||||
|
jsPsych
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetRect = displayElement.querySelector("#target").getBoundingClientRect();
|
||||||
|
|
||||||
|
mouseMove(50, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseMove(55, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseMove(60, 50, displayElement.querySelector("#target"));
|
||||||
|
|
||||||
|
pressKey("a");
|
||||||
|
|
||||||
|
await expectFinished();
|
||||||
|
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[0]).toMatchObject({
|
||||||
|
x: targetRect.x + 50,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousemove",
|
||||||
|
});
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[1]).toMatchObject({
|
||||||
|
x: targetRect.x + 55,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousemove",
|
||||||
|
});
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[2]).toMatchObject({
|
||||||
|
x: targetRect.x + 60,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousemove",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds mouse down data to trial", async () => {
|
||||||
|
const jsPsych = initJsPsych({
|
||||||
|
extensions: [{ type: MouseTrackingExtension }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{
|
||||||
|
type: htmlKeyboardResponse,
|
||||||
|
stimulus: "<div id='target' style='width:500px; height: 500px;'></div>",
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
type: MouseTrackingExtension,
|
||||||
|
params: { events: ["mousedown"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { displayElement, getHTML, getData, expectFinished } = await startTimeline(
|
||||||
|
timeline,
|
||||||
|
jsPsych
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetRect = displayElement.querySelector("#target").getBoundingClientRect();
|
||||||
|
|
||||||
|
mouseDown(50, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseDown(55, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseDown(60, 50, displayElement.querySelector("#target"));
|
||||||
|
|
||||||
|
pressKey("a");
|
||||||
|
|
||||||
|
await expectFinished();
|
||||||
|
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[0]).toMatchObject({
|
||||||
|
x: targetRect.x + 50,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousedown",
|
||||||
|
});
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[1]).toMatchObject({
|
||||||
|
x: targetRect.x + 55,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousedown",
|
||||||
|
});
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[2]).toMatchObject({
|
||||||
|
x: targetRect.x + 60,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousedown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds mouse up data to trial", async () => {
|
||||||
|
const jsPsych = initJsPsych({
|
||||||
|
extensions: [{ type: MouseTrackingExtension }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{
|
||||||
|
type: htmlKeyboardResponse,
|
||||||
|
stimulus: "<div id='target' style='width:500px; height: 500px;'></div>",
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
type: MouseTrackingExtension,
|
||||||
|
params: { events: ["mouseup"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { displayElement, getHTML, getData, expectFinished } = await startTimeline(
|
||||||
|
timeline,
|
||||||
|
jsPsych
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetRect = displayElement.querySelector("#target").getBoundingClientRect();
|
||||||
|
|
||||||
|
mouseUp(50, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseUp(55, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseUp(60, 50, displayElement.querySelector("#target"));
|
||||||
|
|
||||||
|
pressKey("a");
|
||||||
|
|
||||||
|
await expectFinished();
|
||||||
|
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[0]).toMatchObject({
|
||||||
|
x: targetRect.x + 50,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mouseup",
|
||||||
|
});
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[1]).toMatchObject({
|
||||||
|
x: targetRect.x + 55,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mouseup",
|
||||||
|
});
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[2]).toMatchObject({
|
||||||
|
x: targetRect.x + 60,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mouseup",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores mousemove when not in events", async () => {
|
||||||
|
const jsPsych = initJsPsych({
|
||||||
|
extensions: [{ type: MouseTrackingExtension }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{
|
||||||
|
type: htmlKeyboardResponse,
|
||||||
|
stimulus: "<div id='target' style='width:500px; height: 500px;'></div>",
|
||||||
|
extensions: [
|
||||||
|
{
|
||||||
|
type: MouseTrackingExtension,
|
||||||
|
params: { events: ["mousedown"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { displayElement, getHTML, getData, expectFinished } = await startTimeline(
|
||||||
|
timeline,
|
||||||
|
jsPsych
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetRect = displayElement.querySelector("#target").getBoundingClientRect();
|
||||||
|
|
||||||
|
mouseMove(50, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseMove(55, 50, displayElement.querySelector("#target"));
|
||||||
|
mouseDown(60, 50, displayElement.querySelector("#target"));
|
||||||
|
|
||||||
|
pressKey("a");
|
||||||
|
|
||||||
|
await expectFinished();
|
||||||
|
|
||||||
|
expect(getData().values()[0].mouse_tracking_data.length).toBe(1);
|
||||||
|
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[0]).toMatchObject({
|
||||||
|
x: targetRect.x + 60,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousedown",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records bounding rect of targets in data", async () => {
|
||||||
|
const jsPsych = initJsPsych({
|
||||||
|
extensions: [{ type: MouseTrackingExtension }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{
|
||||||
|
type: htmlKeyboardResponse,
|
||||||
|
stimulus: `
|
||||||
|
<div id='target' style='width:500px; height: 500px;'></div>
|
||||||
|
<div id='target2' style='width:200px; height: 200px;'></div>
|
||||||
|
`,
|
||||||
|
extensions: [
|
||||||
|
{ type: MouseTrackingExtension, params: { targets: ["#target", "#target2"] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { displayElement, getHTML, getData, expectFinished } = await startTimeline(
|
||||||
|
timeline,
|
||||||
|
jsPsych
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetRect = displayElement.querySelector("#target").getBoundingClientRect();
|
||||||
|
const target2Rect = displayElement.querySelector("#target2").getBoundingClientRect();
|
||||||
|
|
||||||
|
pressKey("a");
|
||||||
|
|
||||||
|
await expectFinished();
|
||||||
|
|
||||||
|
expect(getData().values()[0].mouse_tracking_targets["#target"]).toEqual(targetRect);
|
||||||
|
expect(getData().values()[0].mouse_tracking_targets["#target2"]).toEqual(target2Rect);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores mousemove events that are faster than minimum_sample_time", async () => {
|
||||||
|
const jsPsych = initJsPsych({
|
||||||
|
extensions: [{ type: MouseTrackingExtension, params: { minimum_sample_time: 100 } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeline = [
|
||||||
|
{
|
||||||
|
type: htmlKeyboardResponse,
|
||||||
|
stimulus: "<div id='target' style='width:500px; height: 500px;'></div>",
|
||||||
|
extensions: [{ type: MouseTrackingExtension }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { displayElement, getHTML, getData, expectFinished } = await startTimeline(
|
||||||
|
timeline,
|
||||||
|
jsPsych
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetRect = displayElement.querySelector("#target").getBoundingClientRect();
|
||||||
|
|
||||||
|
mouseMove(50, 50, displayElement.querySelector("#target"));
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
// this one should be ignored
|
||||||
|
mouseMove(55, 50, displayElement.querySelector("#target"));
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
// this one should register
|
||||||
|
mouseMove(60, 50, displayElement.querySelector("#target"));
|
||||||
|
|
||||||
|
pressKey("a");
|
||||||
|
|
||||||
|
await expectFinished();
|
||||||
|
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[0]).toMatchObject({
|
||||||
|
x: targetRect.x + 50,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousemove",
|
||||||
|
});
|
||||||
|
expect(getData().values()[0].mouse_tracking_data[1]).toMatchObject({
|
||||||
|
x: targetRect.x + 60,
|
||||||
|
y: targetRect.y + 50,
|
||||||
|
event: "mousemove",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
134
packages/extension-mouse-tracking/src/index.ts
Normal file
134
packages/extension-mouse-tracking/src/index.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import { JsPsych, JsPsychExtension, JsPsychExtensionInfo } from "jspsych";
|
||||||
|
|
||||||
|
interface InitializeParameters {
|
||||||
|
/**
|
||||||
|
* The minimum time between samples for `mousemove` events in milliseconds.
|
||||||
|
* If `mousemove` events occur more rapidly than this limit, they will not be recorded.
|
||||||
|
* Use this if you want to keep the data files smaller and don't need high resolution
|
||||||
|
* tracking data. The default value of 0 means that all events will be recorded.
|
||||||
|
* @default 0
|
||||||
|
*/
|
||||||
|
minimum_sample_time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnStartParameters {
|
||||||
|
/**
|
||||||
|
* An array of string selectors. The selectors should identify one unique element on the page.
|
||||||
|
* The DOMRect of the element will be stored in the data.
|
||||||
|
*/
|
||||||
|
targets?: Array<string>;
|
||||||
|
/**
|
||||||
|
* An array of mouse events to track. Can include `"mousemove"`, `"mousedown"`, and `"mouseup"`.
|
||||||
|
* @default ['mousemove']
|
||||||
|
*/
|
||||||
|
events?: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MouseTrackingExtension implements JsPsychExtension {
|
||||||
|
static info: JsPsychExtensionInfo = {
|
||||||
|
name: "mouse-tracking",
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private jsPsych: JsPsych) {}
|
||||||
|
|
||||||
|
private domObserver: MutationObserver;
|
||||||
|
private currentTrialData: Array<object>;
|
||||||
|
private currentTrialTargets: Map<string, DOMRect>;
|
||||||
|
private currentTrialSelectors: Array<string>;
|
||||||
|
private currentTrialStartTime: number;
|
||||||
|
private minimumSampleTime: number;
|
||||||
|
private lastSampleTime: number;
|
||||||
|
private eventsToTrack: Array<string>;
|
||||||
|
|
||||||
|
initialize = async ({ minimum_sample_time = 0 }: InitializeParameters) => {
|
||||||
|
this.domObserver = new MutationObserver(this.mutationObserverCallback);
|
||||||
|
this.minimumSampleTime = minimum_sample_time;
|
||||||
|
};
|
||||||
|
|
||||||
|
on_start = (params: OnStartParameters): void => {
|
||||||
|
params = params || {};
|
||||||
|
|
||||||
|
this.currentTrialData = [];
|
||||||
|
this.currentTrialTargets = new Map();
|
||||||
|
this.currentTrialSelectors = params.targets || [];
|
||||||
|
this.lastSampleTime = null;
|
||||||
|
this.eventsToTrack = params.events || ["mousemove"];
|
||||||
|
|
||||||
|
this.domObserver.observe(this.jsPsych.getDisplayElement(), { childList: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
on_load = () => {
|
||||||
|
// set current trial start time
|
||||||
|
this.currentTrialStartTime = performance.now();
|
||||||
|
|
||||||
|
// start data collection
|
||||||
|
if (this.eventsToTrack.includes("mousemove")) {
|
||||||
|
window.addEventListener("mousemove", this.mouseMoveEventHandler);
|
||||||
|
}
|
||||||
|
if (this.eventsToTrack.includes("mousedown")) {
|
||||||
|
window.addEventListener("mousedown", this.mouseDownEventHandler);
|
||||||
|
}
|
||||||
|
if (this.eventsToTrack.includes("mouseup")) {
|
||||||
|
window.addEventListener("mouseup", this.mouseUpEventHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
on_finish = () => {
|
||||||
|
this.domObserver.disconnect();
|
||||||
|
|
||||||
|
if (this.eventsToTrack.includes("mousemove")) {
|
||||||
|
window.removeEventListener("mousemove", this.mouseMoveEventHandler);
|
||||||
|
}
|
||||||
|
if (this.eventsToTrack.includes("mousedown")) {
|
||||||
|
window.removeEventListener("mousedown", this.mouseDownEventHandler);
|
||||||
|
}
|
||||||
|
if (this.eventsToTrack.includes("mouseup")) {
|
||||||
|
window.removeEventListener("mouseup", this.mouseUpEventHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mouse_tracking_data: this.currentTrialData,
|
||||||
|
mouse_tracking_targets: Object.fromEntries(this.currentTrialTargets.entries()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
private mouseMoveEventHandler = ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||||
|
const event_time = performance.now();
|
||||||
|
const t = Math.round(event_time - this.currentTrialStartTime);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.lastSampleTime === null ||
|
||||||
|
event_time - this.lastSampleTime >= this.minimumSampleTime
|
||||||
|
) {
|
||||||
|
this.lastSampleTime = event_time;
|
||||||
|
this.currentTrialData.push({ x, y, t, event: "mousemove" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private mouseUpEventHandler = ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||||
|
const event_time = performance.now();
|
||||||
|
const t = Math.round(event_time - this.currentTrialStartTime);
|
||||||
|
|
||||||
|
this.currentTrialData.push({ x, y, t, event: "mouseup" });
|
||||||
|
};
|
||||||
|
|
||||||
|
private mouseDownEventHandler = ({ clientX: x, clientY: y }: MouseEvent) => {
|
||||||
|
const event_time = performance.now();
|
||||||
|
const t = Math.round(event_time - this.currentTrialStartTime);
|
||||||
|
|
||||||
|
this.currentTrialData.push({ x, y, t, event: "mousedown" });
|
||||||
|
};
|
||||||
|
|
||||||
|
private mutationObserverCallback = (mutationsList, observer) => {
|
||||||
|
for (const selector of this.currentTrialSelectors) {
|
||||||
|
if (!this.currentTrialTargets.has(selector)) {
|
||||||
|
const target = this.jsPsych.getDisplayElement().querySelector(selector);
|
||||||
|
if (target) {
|
||||||
|
this.currentTrialTargets.set(selector, target.getBoundingClientRect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MouseTrackingExtension;
|
7
packages/extension-mouse-tracking/tsconfig.json
Normal file
7
packages/extension-mouse-tracking/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "@jspsych/config/tsconfig.core.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
@ -28,6 +28,60 @@ export function clickTarget(target: Element) {
|
|||||||
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a `mousemove` event, with x and y defined relative to the container element.
|
||||||
|
* @param x The x location of the event, relative to the x location of `container`.
|
||||||
|
* @param y The y location of the event, relative to the y location of `container`.
|
||||||
|
* @param container The DOM element for relative location of the event.
|
||||||
|
*/
|
||||||
|
export function mouseMove(x: number, y: number, container: Element) {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const eventInit = {
|
||||||
|
clientX: containerRect.x + x,
|
||||||
|
clientY: containerRect.y + y,
|
||||||
|
bubbles: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
container.dispatchEvent(new MouseEvent("mousemove", eventInit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a `mouseup` event, with x and y defined relative to the container element.
|
||||||
|
* @param x The x location of the event, relative to the x location of `container`.
|
||||||
|
* @param y The y location of the event, relative to the y location of `container`.
|
||||||
|
* @param container The DOM element for relative location of the event.
|
||||||
|
*/
|
||||||
|
export function mouseUp(x: number, y: number, container: Element) {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const eventInit = {
|
||||||
|
clientX: containerRect.x + x,
|
||||||
|
clientY: containerRect.y + y,
|
||||||
|
bubbles: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
container.dispatchEvent(new MouseEvent("mouseup", eventInit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a `mousemove` event, with x and y defined relative to the container element.
|
||||||
|
* @param x The x location of the event, relative to the x location of `container`.
|
||||||
|
* @param y The y location of the event, relative to the y location of `container`.
|
||||||
|
* @param container The DOM element for relative location of the event.
|
||||||
|
*/
|
||||||
|
export function mouseDown(x: number, y: number, container: Element) {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
const eventInit = {
|
||||||
|
clientX: containerRect.x + x,
|
||||||
|
clientY: containerRect.y + y,
|
||||||
|
bubbles: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
container.dispatchEvent(new MouseEvent("mousedown", eventInit));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* https://github.com/facebook/jest/issues/2157#issuecomment-279171856
|
* https://github.com/facebook/jest/issues/2157#issuecomment-279171856
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user