@jspsych/plugin-tobii-user-position 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/dist/index.js ADDED
@@ -0,0 +1,687 @@
1
+ import { ParameterType } from 'jspsych';
2
+
3
+ var version = "0.1.1";
4
+
5
+ class PositionDisplay {
6
+ constructor(container, options) {
7
+ this.BOX_WIDTH = 400;
8
+ this.BOX_HEIGHT = 300;
9
+ this.MIN_FACE_SCALE = 0.4;
10
+ this.MAX_FACE_SCALE = 1.6;
11
+ this.OPTIMAL_FACE_SCALE = 1;
12
+ this.container = container;
13
+ this.options = options;
14
+ this.createDisplay();
15
+ }
16
+ createDisplay() {
17
+ this.container.innerHTML = "";
18
+ this.messageElement = document.createElement("div");
19
+ this.messageElement.className = "tobii-user-position-message";
20
+ this.messageElement.textContent = this.options.message;
21
+ this.container.appendChild(this.messageElement);
22
+ const guideContainer = document.createElement("div");
23
+ guideContainer.className = "tobii-user-position-guide";
24
+ this.container.appendChild(guideContainer);
25
+ this.trackingBoxElement = document.createElement("div");
26
+ this.trackingBoxElement.className = "tobii-tracking-box";
27
+ this.trackingBoxElement.setAttribute("role", "img");
28
+ this.trackingBoxElement.setAttribute("aria-label", "Head position tracking display");
29
+ this.trackingBoxElement.style.cssText = `
30
+ position: relative;
31
+ width: ${this.BOX_WIDTH}px;
32
+ height: ${this.BOX_HEIGHT}px;
33
+ border: 3px solid #666;
34
+ border-radius: 12px;
35
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
36
+ overflow: hidden;
37
+ box-shadow: inset 0 0 30px rgba(0,0,0,0.5);
38
+ `;
39
+ guideContainer.appendChild(this.trackingBoxElement);
40
+ const optimalZone = document.createElement("div");
41
+ optimalZone.style.cssText = `
42
+ position: absolute;
43
+ top: 50%;
44
+ left: 50%;
45
+ transform: translate(-50%, -50%);
46
+ width: 60%;
47
+ height: 70%;
48
+ border: 2px dashed rgba(255,255,255,0.2);
49
+ border-radius: 8px;
50
+ `;
51
+ this.trackingBoxElement.appendChild(optimalZone);
52
+ this.faceOutlineElement = document.createElement("div");
53
+ this.faceOutlineElement.className = "tobii-face-outline";
54
+ this.faceOutlineElement.setAttribute("aria-hidden", "true");
55
+ this.faceOutlineElement.style.cssText = `
56
+ position: absolute;
57
+ top: 50%;
58
+ left: 50%;
59
+ transform: translate(-50%, -50%);
60
+ transition: all 0.1s ease-out;
61
+ `;
62
+ this.trackingBoxElement.appendChild(this.faceOutlineElement);
63
+ this.createFaceSVG();
64
+ if (this.options.showDistanceFeedback) {
65
+ this.distanceBarContainer = document.createElement("div");
66
+ this.distanceBarContainer.style.cssText = `
67
+ position: absolute;
68
+ right: -40px;
69
+ top: 10%;
70
+ width: 20px;
71
+ height: 80%;
72
+ background: rgba(0,0,0,0.3);
73
+ border-radius: 10px;
74
+ border: 2px solid #444;
75
+ overflow: hidden;
76
+ `;
77
+ guideContainer.appendChild(this.distanceBarContainer);
78
+ const closeLabel = document.createElement("div");
79
+ closeLabel.textContent = "Close";
80
+ closeLabel.style.cssText = `
81
+ position: absolute;
82
+ right: -70px;
83
+ top: 0;
84
+ font-size: 10px;
85
+ color: #888;
86
+ `;
87
+ guideContainer.appendChild(closeLabel);
88
+ const farLabel = document.createElement("div");
89
+ farLabel.textContent = "Far";
90
+ farLabel.style.cssText = `
91
+ position: absolute;
92
+ right: -55px;
93
+ bottom: 10%;
94
+ font-size: 10px;
95
+ color: #888;
96
+ `;
97
+ guideContainer.appendChild(farLabel);
98
+ this.distanceBarFill = document.createElement("div");
99
+ this.distanceBarFill.style.cssText = `
100
+ position: absolute;
101
+ bottom: 0;
102
+ left: 0;
103
+ width: 100%;
104
+ height: 50%;
105
+ background: ${this.options.goodColor};
106
+ border-radius: 8px;
107
+ transition: all 0.1s ease-out;
108
+ `;
109
+ this.distanceBarContainer.appendChild(this.distanceBarFill);
110
+ const optimalMarker = document.createElement("div");
111
+ optimalMarker.style.cssText = `
112
+ position: absolute;
113
+ left: -2px;
114
+ right: -2px;
115
+ top: 40%;
116
+ height: 20%;
117
+ border: 2px solid rgba(255,255,255,0.5);
118
+ border-radius: 4px;
119
+ pointer-events: none;
120
+ `;
121
+ this.distanceBarContainer.appendChild(optimalMarker);
122
+ }
123
+ this.feedbackElement = document.createElement("div");
124
+ this.feedbackElement.className = "tobii-position-feedback";
125
+ this.feedbackElement.setAttribute("role", "status");
126
+ this.feedbackElement.setAttribute("aria-live", "polite");
127
+ this.feedbackElement.style.cssText = `
128
+ margin-top: 20px;
129
+ font-size: 1.1em;
130
+ font-weight: 600;
131
+ text-align: center;
132
+ `;
133
+ this.container.appendChild(this.feedbackElement);
134
+ }
135
+ createFaceSVG() {
136
+ const baseWidth = 120;
137
+ const baseHeight = 150;
138
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
139
+ svg.setAttribute("width", `${baseWidth}`);
140
+ svg.setAttribute("height", `${baseHeight}`);
141
+ svg.setAttribute("viewBox", `0 0 ${baseWidth} ${baseHeight}`);
142
+ svg.style.cssText = "overflow: visible;";
143
+ const faceOutline = document.createElementNS("http://www.w3.org/2000/svg", "ellipse");
144
+ faceOutline.setAttribute("cx", "60");
145
+ faceOutline.setAttribute("cy", "80");
146
+ faceOutline.setAttribute("rx", "50");
147
+ faceOutline.setAttribute("ry", "65");
148
+ faceOutline.setAttribute("fill", "none");
149
+ faceOutline.setAttribute("stroke", "#888");
150
+ faceOutline.setAttribute("stroke-width", "3");
151
+ faceOutline.setAttribute("stroke-dasharray", "8,4");
152
+ svg.appendChild(faceOutline);
153
+ this.leftEyeElement = document.createElementNS("http://www.w3.org/2000/svg", "circle");
154
+ this.leftEyeElement.setAttribute("cx", "40");
155
+ this.leftEyeElement.setAttribute("cy", "65");
156
+ this.leftEyeElement.setAttribute("r", "12");
157
+ this.leftEyeElement.setAttribute("fill", this.options.poorColor);
158
+ this.leftEyeElement.setAttribute("stroke", "#fff");
159
+ this.leftEyeElement.setAttribute("stroke-width", "2");
160
+ svg.appendChild(this.leftEyeElement);
161
+ this.rightEyeElement = document.createElementNS("http://www.w3.org/2000/svg", "circle");
162
+ this.rightEyeElement.setAttribute("cx", "80");
163
+ this.rightEyeElement.setAttribute("cy", "65");
164
+ this.rightEyeElement.setAttribute("r", "12");
165
+ this.rightEyeElement.setAttribute("fill", this.options.poorColor);
166
+ this.rightEyeElement.setAttribute("stroke", "#fff");
167
+ this.rightEyeElement.setAttribute("stroke-width", "2");
168
+ svg.appendChild(this.rightEyeElement);
169
+ const nose = document.createElementNS("http://www.w3.org/2000/svg", "path");
170
+ nose.setAttribute("d", "M60,75 L55,95 L65,95 Z");
171
+ nose.setAttribute("fill", "none");
172
+ nose.setAttribute("stroke", "#666");
173
+ nose.setAttribute("stroke-width", "2");
174
+ svg.appendChild(nose);
175
+ this.faceOutlineElement.appendChild(svg);
176
+ }
177
+ /**
178
+ * Update the display with new position data
179
+ */
180
+ updatePosition(positionData) {
181
+ if (!positionData) {
182
+ this.showNoData();
183
+ return;
184
+ }
185
+ const avgX = this.getAveragePosition(positionData.leftX, positionData.rightX, positionData.leftValid, positionData.rightValid);
186
+ const avgY = this.getAveragePosition(positionData.leftY, positionData.rightY, positionData.leftValid, positionData.rightValid);
187
+ const avgZ = this.getAveragePosition(positionData.leftZ, positionData.rightZ, positionData.leftValid, positionData.rightValid);
188
+ this.updateFaceDisplay(avgX, avgY, avgZ, positionData);
189
+ if (this.options.showDistanceFeedback && avgZ !== null) {
190
+ this.updateDistanceBar(avgZ);
191
+ }
192
+ this.updateEyeIndicators(positionData);
193
+ this.updateTextualFeedback(avgX, avgY, avgZ);
194
+ }
195
+ getAveragePosition(left, right, leftValid, rightValid) {
196
+ if (leftValid && rightValid && left !== null && right !== null) {
197
+ return (left + right) / 2;
198
+ } else if (leftValid && left !== null) {
199
+ return left;
200
+ } else if (rightValid && right !== null) {
201
+ return right;
202
+ }
203
+ return null;
204
+ }
205
+ updateFaceDisplay(x, y, z, _positionData) {
206
+ if (x === null || y === null) {
207
+ this.faceOutlineElement.style.opacity = "0.3";
208
+ return;
209
+ }
210
+ this.faceOutlineElement.style.opacity = "1";
211
+ const offsetX = (x - 0.5) * this.BOX_WIDTH * 0.8;
212
+ const offsetY = (0.5 - y) * this.BOX_HEIGHT * 0.8;
213
+ let scale = this.OPTIMAL_FACE_SCALE;
214
+ if (z !== null) {
215
+ scale = this.MIN_FACE_SCALE + z * (this.MAX_FACE_SCALE - this.MIN_FACE_SCALE);
216
+ scale = Math.max(this.MIN_FACE_SCALE, Math.min(this.MAX_FACE_SCALE, scale));
217
+ }
218
+ this.faceOutlineElement.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px)) scale(${scale})`;
219
+ }
220
+ updateDistanceBar(z) {
221
+ if (!this.distanceBarFill)
222
+ return;
223
+ const fillPercent = z * 100;
224
+ this.distanceBarFill.style.height = `${fillPercent}%`;
225
+ const goodMin = 0.5 - this.options.distanceThresholdGood;
226
+ const goodMax = 0.5 + this.options.distanceThresholdGood;
227
+ const fairMin = 0.5 - this.options.distanceThresholdFair;
228
+ const fairMax = 0.5 + this.options.distanceThresholdFair;
229
+ let color;
230
+ if (z >= goodMin && z <= goodMax) {
231
+ color = this.options.goodColor;
232
+ } else if (z >= fairMin && z <= fairMax) {
233
+ color = this.options.fairColor;
234
+ } else {
235
+ color = this.options.poorColor;
236
+ }
237
+ this.distanceBarFill.style.background = color;
238
+ }
239
+ updateEyeIndicators(positionData) {
240
+ if (this.leftEyeElement) {
241
+ const leftColor = positionData.leftValid ? this.options.goodColor : this.options.poorColor;
242
+ this.leftEyeElement.setAttribute("fill", leftColor);
243
+ }
244
+ if (this.rightEyeElement) {
245
+ const rightColor = positionData.rightValid ? this.options.goodColor : this.options.poorColor;
246
+ this.rightEyeElement.setAttribute("fill", rightColor);
247
+ }
248
+ }
249
+ updateTextualFeedback(x, y, z) {
250
+ if (!this.feedbackElement)
251
+ return;
252
+ if (x === null || y === null || z === null) {
253
+ this.feedbackElement.textContent = "Eyes not detected - please position yourself in front of the tracker";
254
+ this.feedbackElement.style.color = this.options.poorColor;
255
+ return;
256
+ }
257
+ const quality = this.assessPositionQuality(x, y, z);
258
+ let feedback;
259
+ let color;
260
+ if (quality.isGoodPosition) {
261
+ feedback = "\u2713 Position is good";
262
+ color = this.options.goodColor;
263
+ } else {
264
+ const issues = [];
265
+ const posFairThreshold = this.options.positionThresholdFair;
266
+ if (x < 0.5 - posFairThreshold)
267
+ issues.push("move right");
268
+ else if (x > 0.5 + posFairThreshold)
269
+ issues.push("move left");
270
+ if (y < 0.5 - posFairThreshold)
271
+ issues.push("move up");
272
+ else if (y > 0.5 + posFairThreshold)
273
+ issues.push("move down");
274
+ const distFairThreshold = this.options.distanceThresholdFair;
275
+ if (z > 0.5 + distFairThreshold)
276
+ issues.push("move back");
277
+ else if (z < 0.5 - distFairThreshold)
278
+ issues.push("move closer");
279
+ if (issues.length > 0) {
280
+ feedback = `Please ${issues.join(" and ")}`;
281
+ } else {
282
+ feedback = "Position: Almost there...";
283
+ }
284
+ color = quality.distanceStatus === "poor" || quality.horizontalStatus === "poor" || quality.verticalStatus === "poor" ? this.options.poorColor : this.options.fairColor;
285
+ }
286
+ this.feedbackElement.textContent = feedback;
287
+ this.feedbackElement.style.color = color;
288
+ }
289
+ assessPositionQuality(x, y, z) {
290
+ const xOffset = Math.abs(x - 0.5);
291
+ let horizontalStatus;
292
+ if (xOffset < this.options.positionThresholdGood)
293
+ horizontalStatus = "good";
294
+ else if (xOffset < this.options.positionThresholdFair)
295
+ horizontalStatus = "fair";
296
+ else
297
+ horizontalStatus = "poor";
298
+ const yOffset = Math.abs(y - 0.5);
299
+ let verticalStatus;
300
+ if (yOffset < this.options.positionThresholdGood)
301
+ verticalStatus = "good";
302
+ else if (yOffset < this.options.positionThresholdFair)
303
+ verticalStatus = "fair";
304
+ else
305
+ verticalStatus = "poor";
306
+ const zOffset = Math.abs(z - 0.5);
307
+ let distanceStatus;
308
+ if (zOffset < this.options.distanceThresholdGood)
309
+ distanceStatus = "good";
310
+ else if (zOffset < this.options.distanceThresholdFair)
311
+ distanceStatus = "fair";
312
+ else
313
+ distanceStatus = "poor";
314
+ const isGoodPosition = horizontalStatus === "good" && verticalStatus === "good" && distanceStatus === "good";
315
+ return {
316
+ isGoodPosition,
317
+ horizontalStatus,
318
+ verticalStatus,
319
+ distanceStatus,
320
+ averageX: x,
321
+ averageY: y,
322
+ averageZ: z
323
+ };
324
+ }
325
+ showNoData() {
326
+ if (this.feedbackElement) {
327
+ this.feedbackElement.textContent = "Waiting for tracker data...";
328
+ this.feedbackElement.style.color = this.options.poorColor;
329
+ }
330
+ this.faceOutlineElement.style.opacity = "0.3";
331
+ }
332
+ /**
333
+ * Get current position quality
334
+ */
335
+ getCurrentQuality(positionData) {
336
+ if (!positionData) {
337
+ return {
338
+ isGoodPosition: false,
339
+ horizontalStatus: "poor",
340
+ verticalStatus: "poor",
341
+ distanceStatus: "poor",
342
+ averageX: null,
343
+ averageY: null,
344
+ averageZ: null
345
+ };
346
+ }
347
+ const avgX = this.getAveragePosition(positionData.leftX, positionData.rightX, positionData.leftValid, positionData.rightValid);
348
+ const avgY = this.getAveragePosition(positionData.leftY, positionData.rightY, positionData.leftValid, positionData.rightValid);
349
+ const avgZ = this.getAveragePosition(positionData.leftZ, positionData.rightZ, positionData.leftValid, positionData.rightValid);
350
+ if (avgX === null || avgY === null || avgZ === null) {
351
+ return {
352
+ isGoodPosition: false,
353
+ horizontalStatus: "poor",
354
+ verticalStatus: "poor",
355
+ distanceStatus: "poor",
356
+ averageX: avgX,
357
+ averageY: avgY,
358
+ averageZ: avgZ
359
+ };
360
+ }
361
+ return this.assessPositionQuality(avgX, avgY, avgZ);
362
+ }
363
+ /**
364
+ * Remove the display
365
+ */
366
+ destroy() {
367
+ this.container.innerHTML = "";
368
+ }
369
+ }
370
+
371
+ const info = {
372
+ name: "tobii-user-position",
373
+ version,
374
+ parameters: {
375
+ /** Duration to show the position guide (ms), null for manual */
376
+ duration: {
377
+ type: ParameterType.INT,
378
+ default: null
379
+ },
380
+ /** Message to display */
381
+ message: {
382
+ type: ParameterType.STRING,
383
+ default: "Please position yourself so the indicators are green"
384
+ },
385
+ /** Update interval (ms) */
386
+ update_interval: {
387
+ type: ParameterType.INT,
388
+ default: 100
389
+ },
390
+ /** Show distance feedback */
391
+ show_distance_feedback: {
392
+ type: ParameterType.BOOL,
393
+ default: true
394
+ },
395
+ /** Show position feedback */
396
+ show_position_feedback: {
397
+ type: ParameterType.BOOL,
398
+ default: true
399
+ },
400
+ /** Button text for manual continuation */
401
+ button_text: {
402
+ type: ParameterType.STRING,
403
+ default: "Continue"
404
+ },
405
+ /** Only show button when position is good */
406
+ require_good_position: {
407
+ type: ParameterType.BOOL,
408
+ default: false
409
+ },
410
+ /** Background color */
411
+ background_color: {
412
+ type: ParameterType.STRING,
413
+ default: "#f0f0f0"
414
+ },
415
+ /** Good position color */
416
+ good_color: {
417
+ type: ParameterType.STRING,
418
+ default: "#28a745"
419
+ },
420
+ /** Fair position color */
421
+ fair_color: {
422
+ type: ParameterType.STRING,
423
+ default: "#ffc107"
424
+ },
425
+ /** Poor position color */
426
+ poor_color: {
427
+ type: ParameterType.STRING,
428
+ default: "#dc3545"
429
+ },
430
+ /** Button color */
431
+ button_color: {
432
+ type: ParameterType.STRING,
433
+ default: "#007bff"
434
+ },
435
+ /** Button hover color */
436
+ button_hover_color: {
437
+ type: ParameterType.STRING,
438
+ default: "#0056b3"
439
+ },
440
+ /** Font size */
441
+ font_size: {
442
+ type: ParameterType.STRING,
443
+ default: "18px"
444
+ },
445
+ /** Position offset threshold for "good" status (normalized, default 0.15) */
446
+ position_threshold_good: {
447
+ type: ParameterType.FLOAT,
448
+ default: 0.15
449
+ },
450
+ /** Position offset threshold for "fair" status (normalized, default 0.25) */
451
+ position_threshold_fair: {
452
+ type: ParameterType.FLOAT,
453
+ default: 0.25
454
+ },
455
+ /** Distance offset threshold for "good" status (normalized, default 0.1) */
456
+ distance_threshold_good: {
457
+ type: ParameterType.FLOAT,
458
+ default: 0.1
459
+ },
460
+ /** Distance offset threshold for "fair" status (normalized, default 0.2) */
461
+ distance_threshold_fair: {
462
+ type: ParameterType.FLOAT,
463
+ default: 0.2
464
+ }
465
+ },
466
+ data: {
467
+ /** Average X position during trial */
468
+ average_x: {
469
+ type: ParameterType.FLOAT
470
+ },
471
+ /** Average Y position during trial */
472
+ average_y: {
473
+ type: ParameterType.FLOAT
474
+ },
475
+ /** Average Z position (distance) during trial */
476
+ average_z: {
477
+ type: ParameterType.FLOAT
478
+ },
479
+ /** Whether position was good at end */
480
+ position_good: {
481
+ type: ParameterType.BOOL
482
+ },
483
+ /** Horizontal position status */
484
+ horizontal_status: {
485
+ type: ParameterType.STRING
486
+ },
487
+ /** Vertical position status */
488
+ vertical_status: {
489
+ type: ParameterType.STRING
490
+ },
491
+ /** Distance status */
492
+ distance_status: {
493
+ type: ParameterType.STRING
494
+ },
495
+ /** Duration of trial */
496
+ rt: {
497
+ type: ParameterType.INT
498
+ }
499
+ }
500
+ };
501
+ class TobiiUserPositionPlugin {
502
+ constructor(jsPsych) {
503
+ this.jsPsych = jsPsych;
504
+ }
505
+ static {
506
+ this.info = info;
507
+ }
508
+ static removeStyles() {
509
+ const el = document.getElementById("tobii-user-position-styles");
510
+ if (el) {
511
+ el.remove();
512
+ }
513
+ }
514
+ injectStyles(trial) {
515
+ TobiiUserPositionPlugin.removeStyles();
516
+ const css = `
517
+ .tobii-user-position-container {
518
+ display: flex;
519
+ flex-direction: column;
520
+ align-items: center;
521
+ justify-content: center;
522
+ width: 100%;
523
+ height: 100vh;
524
+ font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
525
+ font-size: ${trial.font_size};
526
+ }
527
+
528
+ .tobii-user-position-message {
529
+ margin-bottom: 40px;
530
+ text-align: center;
531
+ font-weight: 500;
532
+ color: #333;
533
+ }
534
+
535
+ .tobii-user-position-guide {
536
+ position: relative;
537
+ margin-bottom: 40px;
538
+ }
539
+
540
+ .tobii-position-feedback {
541
+ text-align: center;
542
+ margin-bottom: 30px;
543
+ font-weight: 600;
544
+ font-size: 1.1em;
545
+ }
546
+
547
+ .tobii-user-position-button {
548
+ padding: 12px 32px;
549
+ font-size: 16px;
550
+ font-weight: 500;
551
+ border: none;
552
+ border-radius: 6px;
553
+ background-color: ${trial.button_color};
554
+ color: white;
555
+ cursor: pointer;
556
+ transition: all 0.2s ease;
557
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
558
+ }
559
+
560
+ .tobii-user-position-button:hover:not(:disabled) {
561
+ background-color: ${trial.button_hover_color};
562
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
563
+ transform: translateY(-1px);
564
+ }
565
+
566
+ .tobii-user-position-button:disabled {
567
+ background-color: #ccc;
568
+ cursor: not-allowed;
569
+ opacity: 0.6;
570
+ }
571
+
572
+ .tobii-center-marker {
573
+ position: absolute;
574
+ top: 50%;
575
+ left: 50%;
576
+ transform: translate(-50%, -50%);
577
+ width: 30px;
578
+ height: 30px;
579
+ border: 2px dashed #666;
580
+ border-radius: 50%;
581
+ opacity: 0.5;
582
+ }
583
+ `;
584
+ const styleElement = document.createElement("style");
585
+ styleElement.id = "tobii-user-position-styles";
586
+ styleElement.textContent = css;
587
+ document.head.appendChild(styleElement);
588
+ }
589
+ trial(display_element, trial) {
590
+ return new Promise((resolve) => {
591
+ this.injectStyles(trial);
592
+ const tobiiExtension = this.jsPsych.extensions.tobii;
593
+ if (!tobiiExtension) {
594
+ throw new Error("Tobii extension not loaded");
595
+ }
596
+ display_element.innerHTML = `
597
+ <div class="tobii-user-position-container">
598
+ </div>
599
+ `;
600
+ const container = display_element.querySelector(
601
+ ".tobii-user-position-container"
602
+ );
603
+ const positionDisplay = new PositionDisplay(container, {
604
+ message: trial.message,
605
+ showDistanceFeedback: trial.show_distance_feedback,
606
+ showPositionFeedback: trial.show_position_feedback,
607
+ backgroundColor: trial.background_color,
608
+ goodColor: trial.good_color,
609
+ fairColor: trial.fair_color,
610
+ poorColor: trial.poor_color,
611
+ fontSize: trial.font_size,
612
+ positionThresholdGood: trial.position_threshold_good,
613
+ positionThresholdFair: trial.position_threshold_fair,
614
+ distanceThresholdGood: trial.distance_threshold_good,
615
+ distanceThresholdFair: trial.distance_threshold_fair
616
+ });
617
+ let continueButton = null;
618
+ if (trial.duration === null) {
619
+ continueButton = document.createElement("button");
620
+ continueButton.className = "tobii-user-position-button";
621
+ continueButton.textContent = trial.button_text;
622
+ if (trial.require_good_position) {
623
+ continueButton.disabled = true;
624
+ }
625
+ container.appendChild(continueButton);
626
+ }
627
+ const positionSamples = [];
628
+ const startTime = performance.now();
629
+ const updateInterval = setInterval(async () => {
630
+ try {
631
+ const positionData = await tobiiExtension.getUserPosition();
632
+ positionDisplay.updatePosition(positionData);
633
+ const quality = positionDisplay.getCurrentQuality(positionData);
634
+ positionSamples.push(quality);
635
+ if (continueButton && trial.require_good_position) {
636
+ continueButton.disabled = !quality.isGoodPosition;
637
+ }
638
+ } catch (error) {
639
+ console.error("Error updating user position:", error);
640
+ }
641
+ }, trial.update_interval);
642
+ const cleanup = () => {
643
+ clearInterval(updateInterval);
644
+ positionDisplay.destroy();
645
+ display_element.innerHTML = "";
646
+ TobiiUserPositionPlugin.removeStyles();
647
+ };
648
+ const endTrial = () => {
649
+ const validSamples = positionSamples.filter(
650
+ (s) => s.averageX !== null && s.averageY !== null && s.averageZ !== null
651
+ );
652
+ let averageX = null;
653
+ let averageY = null;
654
+ let averageZ = null;
655
+ let finalQuality = null;
656
+ if (validSamples.length > 0) {
657
+ averageX = validSamples.reduce((sum, s) => sum + s.averageX, 0) / validSamples.length;
658
+ averageY = validSamples.reduce((sum, s) => sum + s.averageY, 0) / validSamples.length;
659
+ averageZ = validSamples.reduce((sum, s) => sum + s.averageZ, 0) / validSamples.length;
660
+ finalQuality = positionSamples[positionSamples.length - 1];
661
+ }
662
+ const trialData = {
663
+ average_x: averageX,
664
+ average_y: averageY,
665
+ average_z: averageZ,
666
+ position_good: finalQuality?.isGoodPosition ?? false,
667
+ horizontal_status: finalQuality?.horizontalStatus ?? "poor",
668
+ vertical_status: finalQuality?.verticalStatus ?? "poor",
669
+ distance_status: finalQuality?.distanceStatus ?? "poor",
670
+ rt: Math.round(performance.now() - startTime)
671
+ };
672
+ cleanup();
673
+ this.jsPsych.finishTrial(trialData);
674
+ resolve();
675
+ };
676
+ if (continueButton) {
677
+ continueButton.addEventListener("click", endTrial);
678
+ }
679
+ if (trial.duration != null) {
680
+ this.jsPsych.pluginAPI.setTimeout(endTrial, trial.duration);
681
+ }
682
+ });
683
+ }
684
+ }
685
+
686
+ export { TobiiUserPositionPlugin as default };
687
+ //# sourceMappingURL=index.js.map