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