@jspsych/plugin-tobii-calibration 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/dist/index.browser.js +840 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.min.js +206 -0
- package/dist/index.browser.min.js.map +1 -0
- package/dist/index.cjs +839 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/calibration-display.d.ts +78 -0
- package/src/calibration-display.d.ts.map +1 -0
- package/src/calibration-display.js +293 -0
- package/src/calibration-display.js.map +1 -0
- package/src/calibration-display.ts +359 -0
- package/src/index.d.ts +297 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +597 -0
- package/src/index.js.map +1 -0
- package/src/index.spec.d.ts +2 -0
- package/src/index.spec.d.ts.map +1 -0
- package/src/index.spec.js +125 -0
- package/src/index.spec.js.map +1 -0
- package/src/index.spec.ts +156 -0
- package/src/index.ts +646 -0
- package/src/types.d.ts +52 -0
- package/src/types.d.ts.map +1 -0
- package/src/types.js +5 -0
- package/src/types.js.map +1 -0
- package/src/types.ts +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { ParameterType } from 'jspsych';
|
|
2
|
+
|
|
3
|
+
var version = "0.1.1";
|
|
4
|
+
|
|
5
|
+
class CalibrationDisplay {
|
|
6
|
+
constructor(displayElement, params) {
|
|
7
|
+
this.displayElement = displayElement;
|
|
8
|
+
this.params = params;
|
|
9
|
+
this.currentPoint = null;
|
|
10
|
+
this.progressElement = null;
|
|
11
|
+
this.currentX = 0.5;
|
|
12
|
+
this.currentY = 0.5;
|
|
13
|
+
this.container = this.createContainer();
|
|
14
|
+
this.displayElement.appendChild(this.container);
|
|
15
|
+
if (params.show_progress) {
|
|
16
|
+
this.progressElement = this.createProgressIndicator();
|
|
17
|
+
this.displayElement.appendChild(this.progressElement);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Create container element
|
|
22
|
+
*/
|
|
23
|
+
createContainer() {
|
|
24
|
+
const container = document.createElement("div");
|
|
25
|
+
container.className = "tobii-calibration-container";
|
|
26
|
+
return container;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Create progress indicator
|
|
30
|
+
*/
|
|
31
|
+
createProgressIndicator() {
|
|
32
|
+
const progress = document.createElement("div");
|
|
33
|
+
progress.className = "tobii-calibration-progress";
|
|
34
|
+
progress.setAttribute("role", "status");
|
|
35
|
+
progress.setAttribute("aria-live", "polite");
|
|
36
|
+
return progress;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Show instructions
|
|
40
|
+
*/
|
|
41
|
+
async showInstructions() {
|
|
42
|
+
const wrapper = document.createElement("div");
|
|
43
|
+
wrapper.className = "tobii-calibration-instructions";
|
|
44
|
+
wrapper.setAttribute("role", "dialog");
|
|
45
|
+
wrapper.setAttribute("aria-label", "Eye tracker calibration instructions");
|
|
46
|
+
const content = document.createElement("div");
|
|
47
|
+
content.className = "instructions-content";
|
|
48
|
+
const heading = document.createElement("h2");
|
|
49
|
+
heading.textContent = "Eye Tracker Calibration";
|
|
50
|
+
content.appendChild(heading);
|
|
51
|
+
const paragraph = document.createElement("p");
|
|
52
|
+
paragraph.innerHTML = this.params.instructions || "Look at each point and follow the instructions.";
|
|
53
|
+
content.appendChild(paragraph);
|
|
54
|
+
if (this.params.calibration_mode === "click") {
|
|
55
|
+
const button = document.createElement("button");
|
|
56
|
+
button.className = "calibration-start-btn";
|
|
57
|
+
button.textContent = this.params.button_text || "Start Calibration";
|
|
58
|
+
content.appendChild(button);
|
|
59
|
+
} else {
|
|
60
|
+
const autoMsg = document.createElement("p");
|
|
61
|
+
autoMsg.textContent = "Starting in a moment...";
|
|
62
|
+
content.appendChild(autoMsg);
|
|
63
|
+
}
|
|
64
|
+
wrapper.appendChild(content);
|
|
65
|
+
this.container.appendChild(wrapper);
|
|
66
|
+
if (this.params.calibration_mode === "click") {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const button = wrapper.querySelector("button");
|
|
69
|
+
button?.addEventListener("click", () => {
|
|
70
|
+
wrapper.remove();
|
|
71
|
+
resolve();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
} else {
|
|
75
|
+
await this.delay(this.params.instruction_display_duration || 3e3);
|
|
76
|
+
wrapper.remove();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Initialize the traveling point at screen center
|
|
81
|
+
*/
|
|
82
|
+
async initializePoint() {
|
|
83
|
+
if (this.currentPoint)
|
|
84
|
+
return;
|
|
85
|
+
this.currentPoint = document.createElement("div");
|
|
86
|
+
this.currentPoint.className = "tobii-calibration-point";
|
|
87
|
+
this.currentPoint.setAttribute("role", "img");
|
|
88
|
+
this.currentPoint.setAttribute("aria-label", "Calibration target point");
|
|
89
|
+
const x = 0.5 * window.innerWidth;
|
|
90
|
+
const y = 0.5 * window.innerHeight;
|
|
91
|
+
this.currentX = 0.5;
|
|
92
|
+
this.currentY = 0.5;
|
|
93
|
+
Object.assign(this.currentPoint.style, {
|
|
94
|
+
left: `${x}px`,
|
|
95
|
+
top: `${y}px`,
|
|
96
|
+
width: `${this.params.point_size || 20}px`,
|
|
97
|
+
height: `${this.params.point_size || 20}px`,
|
|
98
|
+
backgroundColor: this.params.point_color || "#ff0000",
|
|
99
|
+
transition: "none"
|
|
100
|
+
});
|
|
101
|
+
this.container.appendChild(this.currentPoint);
|
|
102
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Travel to the next point location with smooth animation
|
|
106
|
+
*/
|
|
107
|
+
async travelToPoint(point, index, total) {
|
|
108
|
+
if (!this.currentPoint) {
|
|
109
|
+
await this.initializePoint();
|
|
110
|
+
}
|
|
111
|
+
if (this.progressElement) {
|
|
112
|
+
this.progressElement.textContent = `Point ${index + 1} of ${total}`;
|
|
113
|
+
}
|
|
114
|
+
this.currentPoint.setAttribute("aria-label", `Calibration target point ${index + 1} of ${total}`);
|
|
115
|
+
const dx = point.x - this.currentX;
|
|
116
|
+
const dy = point.y - this.currentY;
|
|
117
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
118
|
+
const travelDuration = Math.max(150, Math.min(400, 150 + distance * 200));
|
|
119
|
+
const x = point.x * window.innerWidth;
|
|
120
|
+
const y = point.y * window.innerHeight;
|
|
121
|
+
this.currentPoint.style.transition = `left ${travelDuration}ms ease-in-out, top ${travelDuration}ms ease-in-out`;
|
|
122
|
+
this.currentPoint.classList.remove("animation-explosion", "animation-shrink", "animation-pulse", "animation-zoom-out", "animation-zoom-in");
|
|
123
|
+
this.currentPoint.style.left = `${x}px`;
|
|
124
|
+
this.currentPoint.style.top = `${y}px`;
|
|
125
|
+
this.currentX = point.x;
|
|
126
|
+
this.currentY = point.y;
|
|
127
|
+
await this.delay(travelDuration);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Play zoom out animation (point grows larger)
|
|
131
|
+
*/
|
|
132
|
+
async playZoomOut() {
|
|
133
|
+
if (!this.currentPoint)
|
|
134
|
+
return;
|
|
135
|
+
this.currentPoint.style.transition = "none";
|
|
136
|
+
this.currentPoint.classList.remove("animation-shrink", "animation-pulse", "animation-explosion", "animation-zoom-in");
|
|
137
|
+
this.currentPoint.classList.add("animation-zoom-out");
|
|
138
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Play zoom in animation (point shrinks to fixation size)
|
|
142
|
+
*/
|
|
143
|
+
async playZoomIn() {
|
|
144
|
+
if (!this.currentPoint)
|
|
145
|
+
return;
|
|
146
|
+
this.currentPoint.classList.remove("animation-zoom-out");
|
|
147
|
+
this.currentPoint.classList.add("animation-zoom-in");
|
|
148
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Play explosion animation on the current point
|
|
152
|
+
*/
|
|
153
|
+
async playExplosion(success) {
|
|
154
|
+
if (!this.currentPoint)
|
|
155
|
+
return;
|
|
156
|
+
this.currentPoint.classList.remove("animation-pulse", "animation-shrink");
|
|
157
|
+
this.currentPoint.classList.add("animation-explosion");
|
|
158
|
+
if (success) {
|
|
159
|
+
this.currentPoint.style.backgroundColor = "#4ade80";
|
|
160
|
+
} else {
|
|
161
|
+
this.currentPoint.style.backgroundColor = "#f87171";
|
|
162
|
+
}
|
|
163
|
+
await this.delay(this.params.explosion_duration || 400);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Hide current calibration point (removes element)
|
|
167
|
+
*/
|
|
168
|
+
async hidePoint() {
|
|
169
|
+
if (this.currentPoint) {
|
|
170
|
+
this.currentPoint.remove();
|
|
171
|
+
this.currentPoint = null;
|
|
172
|
+
}
|
|
173
|
+
await this.delay(200);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Reset point state after explosion (keeps element for continued travel)
|
|
177
|
+
*/
|
|
178
|
+
async resetPointAfterExplosion() {
|
|
179
|
+
if (!this.currentPoint)
|
|
180
|
+
return;
|
|
181
|
+
this.currentPoint.classList.remove("animation-explosion");
|
|
182
|
+
this.currentPoint.style.transform = "translate(-50%, -50%) scale(1)";
|
|
183
|
+
this.currentPoint.style.opacity = "1";
|
|
184
|
+
this.currentPoint.style.backgroundColor = this.params.point_color || "#ff0000";
|
|
185
|
+
await this.delay(50);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Wait for user click (in click mode)
|
|
189
|
+
*/
|
|
190
|
+
waitForClick() {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
const handleClick = () => {
|
|
193
|
+
document.removeEventListener("click", handleClick);
|
|
194
|
+
resolve();
|
|
195
|
+
};
|
|
196
|
+
document.addEventListener("click", handleClick);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Show calibration result
|
|
201
|
+
* @param success Whether calibration succeeded
|
|
202
|
+
* @param canRetry Whether a retry button should be shown on failure
|
|
203
|
+
* @returns 'retry' if user chose to retry, 'continue' otherwise
|
|
204
|
+
*/
|
|
205
|
+
async showResult(success, canRetry = false) {
|
|
206
|
+
const result = document.createElement("div");
|
|
207
|
+
result.className = "tobii-calibration-result";
|
|
208
|
+
result.setAttribute("role", "alert");
|
|
209
|
+
result.setAttribute("aria-live", "assertive");
|
|
210
|
+
if (success) {
|
|
211
|
+
result.innerHTML = `
|
|
212
|
+
<div class="tobii-calibration-result-content success">
|
|
213
|
+
<h2>Calibration Successful!</h2>
|
|
214
|
+
<p>Continuing automatically...</p>
|
|
215
|
+
</div>
|
|
216
|
+
`;
|
|
217
|
+
this.container.appendChild(result);
|
|
218
|
+
await this.delay(this.params.success_display_duration || 2e3);
|
|
219
|
+
result.remove();
|
|
220
|
+
return "continue";
|
|
221
|
+
}
|
|
222
|
+
const buttonsHTML = canRetry ? `<button class="calibration-retry-btn">Retry</button>
|
|
223
|
+
<button class="calibration-continue-btn" style="margin-left: 10px;">Continue</button>` : `<button class="calibration-continue-btn">Continue</button>`;
|
|
224
|
+
result.innerHTML = `
|
|
225
|
+
<div class="tobii-calibration-result-content error">
|
|
226
|
+
<h2>Calibration Failed</h2>
|
|
227
|
+
<p>Please try again or continue.</p>
|
|
228
|
+
${buttonsHTML}
|
|
229
|
+
</div>
|
|
230
|
+
`;
|
|
231
|
+
this.container.appendChild(result);
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
const retryBtn = result.querySelector(".calibration-retry-btn");
|
|
234
|
+
const continueBtn = result.querySelector(".calibration-continue-btn");
|
|
235
|
+
retryBtn?.addEventListener("click", () => {
|
|
236
|
+
result.remove();
|
|
237
|
+
resolve("retry");
|
|
238
|
+
});
|
|
239
|
+
continueBtn?.addEventListener("click", () => {
|
|
240
|
+
result.remove();
|
|
241
|
+
resolve("continue");
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Reset display state for a retry attempt
|
|
247
|
+
*/
|
|
248
|
+
resetForRetry() {
|
|
249
|
+
this.container.innerHTML = "";
|
|
250
|
+
this.currentPoint = null;
|
|
251
|
+
this.currentX = 0.5;
|
|
252
|
+
this.currentY = 0.5;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Clear display
|
|
256
|
+
*/
|
|
257
|
+
clear() {
|
|
258
|
+
this.container.innerHTML = "";
|
|
259
|
+
if (this.progressElement) {
|
|
260
|
+
this.progressElement.textContent = "";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Delay helper
|
|
265
|
+
*/
|
|
266
|
+
delay(ms) {
|
|
267
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const info = {
|
|
272
|
+
name: "tobii-calibration",
|
|
273
|
+
version,
|
|
274
|
+
parameters: {
|
|
275
|
+
/** Number of calibration points (5 or 9) */
|
|
276
|
+
calibration_points: {
|
|
277
|
+
type: ParameterType.INT,
|
|
278
|
+
default: 9
|
|
279
|
+
},
|
|
280
|
+
/** Calibration mode: click or view */
|
|
281
|
+
calibration_mode: {
|
|
282
|
+
type: ParameterType.STRING,
|
|
283
|
+
default: "view"
|
|
284
|
+
},
|
|
285
|
+
/** Size of calibration points in pixels */
|
|
286
|
+
point_size: {
|
|
287
|
+
type: ParameterType.INT,
|
|
288
|
+
default: 20
|
|
289
|
+
},
|
|
290
|
+
/** Color of calibration points */
|
|
291
|
+
point_color: {
|
|
292
|
+
type: ParameterType.STRING,
|
|
293
|
+
default: "#ff0000"
|
|
294
|
+
},
|
|
295
|
+
/** Duration to show each point before data collection (ms) - allows user to fixate */
|
|
296
|
+
point_duration: {
|
|
297
|
+
type: ParameterType.INT,
|
|
298
|
+
default: 500
|
|
299
|
+
},
|
|
300
|
+
/** Show progress indicator */
|
|
301
|
+
show_progress: {
|
|
302
|
+
type: ParameterType.BOOL,
|
|
303
|
+
default: true
|
|
304
|
+
},
|
|
305
|
+
/** Custom calibration points */
|
|
306
|
+
custom_points: {
|
|
307
|
+
type: ParameterType.COMPLEX,
|
|
308
|
+
default: null
|
|
309
|
+
},
|
|
310
|
+
/** Instructions text */
|
|
311
|
+
instructions: {
|
|
312
|
+
type: ParameterType.STRING,
|
|
313
|
+
default: "Look at each point as it appears on the screen. Keep your gaze fixed on each point until it disappears."
|
|
314
|
+
},
|
|
315
|
+
/** Button text for click mode */
|
|
316
|
+
button_text: {
|
|
317
|
+
type: ParameterType.STRING,
|
|
318
|
+
default: "Start Calibration"
|
|
319
|
+
},
|
|
320
|
+
/** Background color of the calibration container */
|
|
321
|
+
background_color: {
|
|
322
|
+
type: ParameterType.STRING,
|
|
323
|
+
default: "#808080"
|
|
324
|
+
},
|
|
325
|
+
/** Primary button color */
|
|
326
|
+
button_color: {
|
|
327
|
+
type: ParameterType.STRING,
|
|
328
|
+
default: "#007bff"
|
|
329
|
+
},
|
|
330
|
+
/** Primary button hover color */
|
|
331
|
+
button_hover_color: {
|
|
332
|
+
type: ParameterType.STRING,
|
|
333
|
+
default: "#0056b3"
|
|
334
|
+
},
|
|
335
|
+
/** Retry button color */
|
|
336
|
+
retry_button_color: {
|
|
337
|
+
type: ParameterType.STRING,
|
|
338
|
+
default: "#dc3545"
|
|
339
|
+
},
|
|
340
|
+
/** Retry button hover color */
|
|
341
|
+
retry_button_hover_color: {
|
|
342
|
+
type: ParameterType.STRING,
|
|
343
|
+
default: "#c82333"
|
|
344
|
+
},
|
|
345
|
+
/** Success message color */
|
|
346
|
+
success_color: {
|
|
347
|
+
type: ParameterType.STRING,
|
|
348
|
+
default: "#28a745"
|
|
349
|
+
},
|
|
350
|
+
/** Error message color */
|
|
351
|
+
error_color: {
|
|
352
|
+
type: ParameterType.STRING,
|
|
353
|
+
default: "#dc3545"
|
|
354
|
+
},
|
|
355
|
+
/** Maximum number of retry attempts allowed on calibration failure */
|
|
356
|
+
max_retries: {
|
|
357
|
+
type: ParameterType.INT,
|
|
358
|
+
default: 1
|
|
359
|
+
},
|
|
360
|
+
/** Duration of zoom in/out animations in ms */
|
|
361
|
+
zoom_duration: {
|
|
362
|
+
type: ParameterType.INT,
|
|
363
|
+
default: 300
|
|
364
|
+
},
|
|
365
|
+
/** Duration of explosion animation in ms */
|
|
366
|
+
explosion_duration: {
|
|
367
|
+
type: ParameterType.INT,
|
|
368
|
+
default: 400
|
|
369
|
+
},
|
|
370
|
+
/** Duration to show success result before auto-advancing in ms */
|
|
371
|
+
success_display_duration: {
|
|
372
|
+
type: ParameterType.INT,
|
|
373
|
+
default: 2e3
|
|
374
|
+
},
|
|
375
|
+
/** Duration to show instructions before auto-advancing in view mode in ms */
|
|
376
|
+
instruction_display_duration: {
|
|
377
|
+
type: ParameterType.INT,
|
|
378
|
+
default: 3e3
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
data: {
|
|
382
|
+
/** Calibration success status */
|
|
383
|
+
calibration_success: {
|
|
384
|
+
type: ParameterType.BOOL
|
|
385
|
+
},
|
|
386
|
+
/** Number of calibration points used */
|
|
387
|
+
num_points: {
|
|
388
|
+
type: ParameterType.INT
|
|
389
|
+
},
|
|
390
|
+
/** Calibration mode used */
|
|
391
|
+
mode: {
|
|
392
|
+
type: ParameterType.STRING
|
|
393
|
+
},
|
|
394
|
+
/** Full calibration result data */
|
|
395
|
+
calibration_data: {
|
|
396
|
+
type: ParameterType.COMPLEX
|
|
397
|
+
},
|
|
398
|
+
/** Number of calibration attempts made */
|
|
399
|
+
num_attempts: {
|
|
400
|
+
type: ParameterType.INT
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
class TobiiCalibrationPlugin {
|
|
405
|
+
constructor(jsPsych) {
|
|
406
|
+
this.jsPsych = jsPsych;
|
|
407
|
+
}
|
|
408
|
+
static {
|
|
409
|
+
this.info = info;
|
|
410
|
+
}
|
|
411
|
+
static removeStyles() {
|
|
412
|
+
const el = document.getElementById("tobii-calibration-styles");
|
|
413
|
+
if (el) {
|
|
414
|
+
el.remove();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
injectStyles(trial) {
|
|
418
|
+
TobiiCalibrationPlugin.removeStyles();
|
|
419
|
+
const css = `
|
|
420
|
+
.tobii-calibration-container {
|
|
421
|
+
position: fixed;
|
|
422
|
+
top: 0;
|
|
423
|
+
left: 0;
|
|
424
|
+
width: 100%;
|
|
425
|
+
height: 100%;
|
|
426
|
+
background-color: ${trial.background_color};
|
|
427
|
+
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
428
|
+
z-index: 9999;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.tobii-calibration-instructions {
|
|
432
|
+
position: absolute;
|
|
433
|
+
top: 50%;
|
|
434
|
+
left: 50%;
|
|
435
|
+
transform: translate(-50%, -50%);
|
|
436
|
+
background-color: white;
|
|
437
|
+
padding: 40px;
|
|
438
|
+
border-radius: 10px;
|
|
439
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
440
|
+
text-align: center;
|
|
441
|
+
max-width: 600px;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.tobii-calibration-instructions h2 {
|
|
445
|
+
margin-top: 0;
|
|
446
|
+
margin-bottom: 20px;
|
|
447
|
+
font-size: 24px;
|
|
448
|
+
color: #333;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.tobii-calibration-instructions p {
|
|
452
|
+
margin-bottom: 20px;
|
|
453
|
+
font-size: 16px;
|
|
454
|
+
line-height: 1.5;
|
|
455
|
+
color: #666;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.calibration-start-btn,
|
|
459
|
+
.calibration-continue-btn,
|
|
460
|
+
.calibration-retry-btn {
|
|
461
|
+
background-color: ${trial.button_color};
|
|
462
|
+
color: white;
|
|
463
|
+
border: none;
|
|
464
|
+
padding: 12px 30px;
|
|
465
|
+
font-size: 16px;
|
|
466
|
+
border-radius: 5px;
|
|
467
|
+
cursor: pointer;
|
|
468
|
+
transition: background-color 0.3s;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.calibration-start-btn:hover,
|
|
472
|
+
.calibration-continue-btn:hover {
|
|
473
|
+
background-color: ${trial.button_hover_color};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.calibration-retry-btn {
|
|
477
|
+
background-color: ${trial.retry_button_color};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.calibration-retry-btn:hover {
|
|
481
|
+
background-color: ${trial.retry_button_hover_color};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.tobii-calibration-point {
|
|
485
|
+
position: absolute;
|
|
486
|
+
border-radius: 50%;
|
|
487
|
+
transform: translate(-50%, -50%);
|
|
488
|
+
cursor: pointer;
|
|
489
|
+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.tobii-calibration-point.animation-pulse {
|
|
493
|
+
animation: tobii-calibration-pulse 1s infinite;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
@keyframes tobii-calibration-pulse {
|
|
497
|
+
0%, 100% {
|
|
498
|
+
transform: translate(-50%, -50%) scale(1);
|
|
499
|
+
opacity: 1;
|
|
500
|
+
}
|
|
501
|
+
50% {
|
|
502
|
+
transform: translate(-50%, -50%) scale(1.2);
|
|
503
|
+
opacity: 0.8;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
.tobii-calibration-point.animation-shrink {
|
|
508
|
+
animation: tobii-calibration-shrink 1s ease-out;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@keyframes tobii-calibration-shrink {
|
|
512
|
+
0% {
|
|
513
|
+
transform: translate(-50%, -50%) scale(3);
|
|
514
|
+
opacity: 0.5;
|
|
515
|
+
}
|
|
516
|
+
100% {
|
|
517
|
+
transform: translate(-50%, -50%) scale(1);
|
|
518
|
+
opacity: 1;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.tobii-calibration-point.animation-explosion {
|
|
523
|
+
animation: tobii-calibration-explosion ${trial.explosion_duration / 1e3}s ease-out forwards;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
@keyframes tobii-calibration-explosion {
|
|
527
|
+
0% {
|
|
528
|
+
transform: translate(-50%, -50%) scale(1);
|
|
529
|
+
opacity: 1;
|
|
530
|
+
}
|
|
531
|
+
50% {
|
|
532
|
+
transform: translate(-50%, -50%) scale(2);
|
|
533
|
+
opacity: 0.8;
|
|
534
|
+
}
|
|
535
|
+
100% {
|
|
536
|
+
transform: translate(-50%, -50%) scale(0);
|
|
537
|
+
opacity: 0;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.tobii-calibration-point.animation-zoom-out {
|
|
542
|
+
animation: tobii-calibration-zoom-out ${trial.zoom_duration / 1e3}s ease-out forwards;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
@keyframes tobii-calibration-zoom-out {
|
|
546
|
+
0% {
|
|
547
|
+
transform: translate(-50%, -50%) scale(1);
|
|
548
|
+
}
|
|
549
|
+
100% {
|
|
550
|
+
transform: translate(-50%, -50%) scale(2.5);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.tobii-calibration-point.animation-zoom-in {
|
|
555
|
+
animation: tobii-calibration-zoom-in ${trial.zoom_duration / 1e3}s ease-out forwards;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
@keyframes tobii-calibration-zoom-in {
|
|
559
|
+
0% {
|
|
560
|
+
transform: translate(-50%, -50%) scale(2.5);
|
|
561
|
+
}
|
|
562
|
+
100% {
|
|
563
|
+
transform: translate(-50%, -50%) scale(1);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.tobii-calibration-progress {
|
|
568
|
+
position: fixed;
|
|
569
|
+
top: 20px;
|
|
570
|
+
left: 50%;
|
|
571
|
+
transform: translateX(-50%);
|
|
572
|
+
background-color: rgba(0, 0, 0, 0.7);
|
|
573
|
+
color: white;
|
|
574
|
+
padding: 10px 20px;
|
|
575
|
+
border-radius: 5px;
|
|
576
|
+
font-size: 14px;
|
|
577
|
+
z-index: 10000;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.tobii-calibration-result {
|
|
581
|
+
position: absolute;
|
|
582
|
+
top: 50%;
|
|
583
|
+
left: 50%;
|
|
584
|
+
transform: translate(-50%, -50%);
|
|
585
|
+
background-color: white;
|
|
586
|
+
padding: 40px;
|
|
587
|
+
border-radius: 10px;
|
|
588
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
589
|
+
text-align: center;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.tobii-calibration-result-content h2 {
|
|
593
|
+
margin-top: 0;
|
|
594
|
+
margin-bottom: 20px;
|
|
595
|
+
font-size: 24px;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.tobii-calibration-result-content.success h2 {
|
|
599
|
+
color: ${trial.success_color};
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.tobii-calibration-result-content.error h2 {
|
|
603
|
+
color: ${trial.error_color};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.tobii-calibration-result-content p {
|
|
607
|
+
margin-bottom: 20px;
|
|
608
|
+
font-size: 16px;
|
|
609
|
+
color: #666;
|
|
610
|
+
}
|
|
611
|
+
`;
|
|
612
|
+
const styleElement = document.createElement("style");
|
|
613
|
+
styleElement.id = "tobii-calibration-styles";
|
|
614
|
+
styleElement.textContent = css;
|
|
615
|
+
document.head.appendChild(styleElement);
|
|
616
|
+
}
|
|
617
|
+
async trial(display_element, trial) {
|
|
618
|
+
this.injectStyles(trial);
|
|
619
|
+
const tobiiExt = this.jsPsych.extensions.tobii;
|
|
620
|
+
if (!tobiiExt) {
|
|
621
|
+
throw new Error("Tobii extension not initialized");
|
|
622
|
+
}
|
|
623
|
+
if (!tobiiExt.isConnected()) {
|
|
624
|
+
throw new Error("Not connected to Tobii server");
|
|
625
|
+
}
|
|
626
|
+
const calibrationDisplay = new CalibrationDisplay(
|
|
627
|
+
display_element,
|
|
628
|
+
trial
|
|
629
|
+
);
|
|
630
|
+
await calibrationDisplay.showInstructions();
|
|
631
|
+
let points;
|
|
632
|
+
if (trial.custom_points) {
|
|
633
|
+
points = this.validateCustomPoints(trial.custom_points);
|
|
634
|
+
} else {
|
|
635
|
+
points = this.getCalibrationPoints(trial.calibration_points);
|
|
636
|
+
}
|
|
637
|
+
const maxAttempts = 1 + trial.max_retries;
|
|
638
|
+
let attempt = 0;
|
|
639
|
+
let calibrationResult = { success: false };
|
|
640
|
+
try {
|
|
641
|
+
while (attempt < maxAttempts) {
|
|
642
|
+
attempt++;
|
|
643
|
+
const retriesRemaining = maxAttempts - attempt;
|
|
644
|
+
await tobiiExt.startCalibration();
|
|
645
|
+
await calibrationDisplay.initializePoint();
|
|
646
|
+
for (let i = 0; i < points.length; i++) {
|
|
647
|
+
const point = points[i];
|
|
648
|
+
await calibrationDisplay.travelToPoint(point, i, points.length);
|
|
649
|
+
await calibrationDisplay.playZoomOut();
|
|
650
|
+
await calibrationDisplay.playZoomIn();
|
|
651
|
+
if (trial.calibration_mode === "click") {
|
|
652
|
+
await calibrationDisplay.waitForClick();
|
|
653
|
+
} else {
|
|
654
|
+
await this.delay(trial.point_duration);
|
|
655
|
+
}
|
|
656
|
+
const result = await tobiiExt.collectCalibrationPoint(point.x, point.y);
|
|
657
|
+
await calibrationDisplay.playExplosion(result.success);
|
|
658
|
+
if (i < points.length - 1) {
|
|
659
|
+
await calibrationDisplay.resetPointAfterExplosion();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
await calibrationDisplay.hidePoint();
|
|
663
|
+
calibrationResult = await tobiiExt.computeCalibration();
|
|
664
|
+
const userChoice = await calibrationDisplay.showResult(
|
|
665
|
+
calibrationResult.success,
|
|
666
|
+
retriesRemaining > 0
|
|
667
|
+
);
|
|
668
|
+
if (userChoice === "continue") {
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
calibrationDisplay.resetForRetry();
|
|
672
|
+
}
|
|
673
|
+
} finally {
|
|
674
|
+
calibrationDisplay.clear();
|
|
675
|
+
display_element.innerHTML = "";
|
|
676
|
+
TobiiCalibrationPlugin.removeStyles();
|
|
677
|
+
}
|
|
678
|
+
const trial_data = {
|
|
679
|
+
calibration_success: calibrationResult.success,
|
|
680
|
+
num_points: points.length,
|
|
681
|
+
mode: trial.calibration_mode,
|
|
682
|
+
calibration_data: calibrationResult,
|
|
683
|
+
num_attempts: attempt
|
|
684
|
+
};
|
|
685
|
+
this.jsPsych.finishTrial(trial_data);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Validate custom calibration points
|
|
689
|
+
*/
|
|
690
|
+
validateCustomPoints(points) {
|
|
691
|
+
if (!Array.isArray(points) || points.length === 0) {
|
|
692
|
+
throw new Error("custom_points must be a non-empty array");
|
|
693
|
+
}
|
|
694
|
+
const validated = [];
|
|
695
|
+
for (let i = 0; i < points.length; i++) {
|
|
696
|
+
const point = points[i];
|
|
697
|
+
if (typeof point !== "object" || point === null) {
|
|
698
|
+
throw new Error(`Invalid calibration point at index ${i}: must have numeric x and y`);
|
|
699
|
+
}
|
|
700
|
+
const p = point;
|
|
701
|
+
if (typeof p.x !== "number" || typeof p.y !== "number") {
|
|
702
|
+
throw new Error(`Invalid calibration point at index ${i}: must have numeric x and y`);
|
|
703
|
+
}
|
|
704
|
+
if (p.x < 0 || p.x > 1 || p.y < 0 || p.y > 1) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
`Calibration point at index ${i} out of range: x and y must be between 0 and 1`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
validated.push({ x: p.x, y: p.y });
|
|
710
|
+
}
|
|
711
|
+
return validated;
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Get standard calibration points for the given grid size
|
|
715
|
+
*/
|
|
716
|
+
getCalibrationPoints(count) {
|
|
717
|
+
switch (count) {
|
|
718
|
+
case 5:
|
|
719
|
+
return [
|
|
720
|
+
{ x: 0.1, y: 0.1 },
|
|
721
|
+
{ x: 0.9, y: 0.1 },
|
|
722
|
+
{ x: 0.5, y: 0.5 },
|
|
723
|
+
{ x: 0.1, y: 0.9 },
|
|
724
|
+
{ x: 0.9, y: 0.9 }
|
|
725
|
+
];
|
|
726
|
+
case 9:
|
|
727
|
+
return [
|
|
728
|
+
{ x: 0.1, y: 0.1 },
|
|
729
|
+
{ x: 0.5, y: 0.1 },
|
|
730
|
+
{ x: 0.9, y: 0.1 },
|
|
731
|
+
{ x: 0.1, y: 0.5 },
|
|
732
|
+
{ x: 0.5, y: 0.5 },
|
|
733
|
+
{ x: 0.9, y: 0.5 },
|
|
734
|
+
{ x: 0.1, y: 0.9 },
|
|
735
|
+
{ x: 0.5, y: 0.9 },
|
|
736
|
+
{ x: 0.9, y: 0.9 }
|
|
737
|
+
];
|
|
738
|
+
case 13:
|
|
739
|
+
return [
|
|
740
|
+
{ x: 0.1, y: 0.1 },
|
|
741
|
+
{ x: 0.5, y: 0.1 },
|
|
742
|
+
{ x: 0.9, y: 0.1 },
|
|
743
|
+
{ x: 0.3, y: 0.3 },
|
|
744
|
+
{ x: 0.7, y: 0.3 },
|
|
745
|
+
{ x: 0.1, y: 0.5 },
|
|
746
|
+
{ x: 0.5, y: 0.5 },
|
|
747
|
+
{ x: 0.9, y: 0.5 },
|
|
748
|
+
{ x: 0.3, y: 0.7 },
|
|
749
|
+
{ x: 0.7, y: 0.7 },
|
|
750
|
+
{ x: 0.1, y: 0.9 },
|
|
751
|
+
{ x: 0.5, y: 0.9 },
|
|
752
|
+
{ x: 0.9, y: 0.9 }
|
|
753
|
+
];
|
|
754
|
+
case 15:
|
|
755
|
+
return [
|
|
756
|
+
{ x: 0.1, y: 0.1 },
|
|
757
|
+
{ x: 0.5, y: 0.1 },
|
|
758
|
+
{ x: 0.9, y: 0.1 },
|
|
759
|
+
{ x: 0.1, y: 0.3 },
|
|
760
|
+
{ x: 0.5, y: 0.3 },
|
|
761
|
+
{ x: 0.9, y: 0.3 },
|
|
762
|
+
{ x: 0.1, y: 0.5 },
|
|
763
|
+
{ x: 0.5, y: 0.5 },
|
|
764
|
+
{ x: 0.9, y: 0.5 },
|
|
765
|
+
{ x: 0.1, y: 0.7 },
|
|
766
|
+
{ x: 0.5, y: 0.7 },
|
|
767
|
+
{ x: 0.9, y: 0.7 },
|
|
768
|
+
{ x: 0.1, y: 0.9 },
|
|
769
|
+
{ x: 0.5, y: 0.9 },
|
|
770
|
+
{ x: 0.9, y: 0.9 }
|
|
771
|
+
];
|
|
772
|
+
case 19:
|
|
773
|
+
return [
|
|
774
|
+
{ x: 0.1, y: 0.1 },
|
|
775
|
+
{ x: 0.5, y: 0.1 },
|
|
776
|
+
{ x: 0.9, y: 0.1 },
|
|
777
|
+
{ x: 0.1, y: 0.3 },
|
|
778
|
+
{ x: 0.3, y: 0.3 },
|
|
779
|
+
{ x: 0.5, y: 0.3 },
|
|
780
|
+
{ x: 0.7, y: 0.3 },
|
|
781
|
+
{ x: 0.9, y: 0.3 },
|
|
782
|
+
{ x: 0.1, y: 0.5 },
|
|
783
|
+
{ x: 0.5, y: 0.5 },
|
|
784
|
+
{ x: 0.9, y: 0.5 },
|
|
785
|
+
{ x: 0.1, y: 0.7 },
|
|
786
|
+
{ x: 0.3, y: 0.7 },
|
|
787
|
+
{ x: 0.5, y: 0.7 },
|
|
788
|
+
{ x: 0.7, y: 0.7 },
|
|
789
|
+
{ x: 0.9, y: 0.7 },
|
|
790
|
+
{ x: 0.1, y: 0.9 },
|
|
791
|
+
{ x: 0.5, y: 0.9 },
|
|
792
|
+
{ x: 0.9, y: 0.9 }
|
|
793
|
+
];
|
|
794
|
+
case 25:
|
|
795
|
+
return [
|
|
796
|
+
{ x: 0.1, y: 0.1 },
|
|
797
|
+
{ x: 0.3, y: 0.1 },
|
|
798
|
+
{ x: 0.5, y: 0.1 },
|
|
799
|
+
{ x: 0.7, y: 0.1 },
|
|
800
|
+
{ x: 0.9, y: 0.1 },
|
|
801
|
+
{ x: 0.1, y: 0.3 },
|
|
802
|
+
{ x: 0.3, y: 0.3 },
|
|
803
|
+
{ x: 0.5, y: 0.3 },
|
|
804
|
+
{ x: 0.7, y: 0.3 },
|
|
805
|
+
{ x: 0.9, y: 0.3 },
|
|
806
|
+
{ x: 0.1, y: 0.5 },
|
|
807
|
+
{ x: 0.3, y: 0.5 },
|
|
808
|
+
{ x: 0.5, y: 0.5 },
|
|
809
|
+
{ x: 0.7, y: 0.5 },
|
|
810
|
+
{ x: 0.9, y: 0.5 },
|
|
811
|
+
{ x: 0.1, y: 0.7 },
|
|
812
|
+
{ x: 0.3, y: 0.7 },
|
|
813
|
+
{ x: 0.5, y: 0.7 },
|
|
814
|
+
{ x: 0.7, y: 0.7 },
|
|
815
|
+
{ x: 0.9, y: 0.7 },
|
|
816
|
+
{ x: 0.1, y: 0.9 },
|
|
817
|
+
{ x: 0.3, y: 0.9 },
|
|
818
|
+
{ x: 0.5, y: 0.9 },
|
|
819
|
+
{ x: 0.7, y: 0.9 },
|
|
820
|
+
{ x: 0.9, y: 0.9 }
|
|
821
|
+
];
|
|
822
|
+
default:
|
|
823
|
+
throw new Error(
|
|
824
|
+
`Unsupported calibration_points value: ${count}. Use 5, 9, 13, 15, 19, or 25, or provide custom_points.`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Delay helper
|
|
830
|
+
*/
|
|
831
|
+
delay(ms) {
|
|
832
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export { TobiiCalibrationPlugin as default };
|
|
837
|
+
//# sourceMappingURL=index.js.map
|