@jspsych/plugin-tobii-validation 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/src/index.ts ADDED
@@ -0,0 +1,799 @@
1
+ /**
2
+ * @title Tobii Validation
3
+ * @description jsPsych plugin for Tobii eye tracker validation. Validates calibration
4
+ * accuracy by measuring gaze error at target points and provides detailed feedback.
5
+ * @version 1.0.0
6
+ * @author jsPsych Team
7
+ * @see {@link https://github.com/jspsych/jspsych-tobii/tree/main/packages/plugin-tobii-validation#readme Documentation}
8
+ */
9
+
10
+ import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from 'jspsych';
11
+ import { version } from '../package.json';
12
+ import type TobiiExtension from '@jspsych/extension-tobii';
13
+ import type { ValidationResult } from '@jspsych/extension-tobii';
14
+ import { ValidationDisplay } from './validation-display';
15
+ import type { ValidationParameters, ValidationPoint } from './types';
16
+
17
+ const info = <const>{
18
+ name: 'tobii-validation',
19
+ version: version,
20
+ parameters: {
21
+ /** Number of validation points (5 or 9) */
22
+ validation_points: {
23
+ type: ParameterType.INT,
24
+ default: 9,
25
+ },
26
+ /** Size of validation points in pixels */
27
+ point_size: {
28
+ type: ParameterType.INT,
29
+ default: 20,
30
+ },
31
+ /** Color of validation points */
32
+ point_color: {
33
+ type: ParameterType.STRING,
34
+ default: '#00ff00',
35
+ },
36
+ /** Duration to collect data at each point (ms) */
37
+ collection_duration: {
38
+ type: ParameterType.INT,
39
+ default: 1000,
40
+ },
41
+ /** Show progress indicator */
42
+ show_progress: {
43
+ type: ParameterType.BOOL,
44
+ default: true,
45
+ },
46
+ /** Custom validation points */
47
+ custom_points: {
48
+ type: ParameterType.COMPLEX,
49
+ default: null,
50
+ },
51
+ /** Show visual feedback */
52
+ show_feedback: {
53
+ type: ParameterType.BOOL,
54
+ default: true,
55
+ },
56
+ /** Instructions text */
57
+ instructions: {
58
+ type: ParameterType.STRING,
59
+ default: 'Look at each point as it appears on the screen to validate calibration accuracy.',
60
+ },
61
+ /** Background color of the validation container */
62
+ background_color: {
63
+ type: ParameterType.STRING,
64
+ default: '#808080',
65
+ },
66
+ /** Primary button color */
67
+ button_color: {
68
+ type: ParameterType.STRING,
69
+ default: '#28a745',
70
+ },
71
+ /** Primary button hover color */
72
+ button_hover_color: {
73
+ type: ParameterType.STRING,
74
+ default: '#218838',
75
+ },
76
+ /** Retry button color */
77
+ retry_button_color: {
78
+ type: ParameterType.STRING,
79
+ default: '#dc3545',
80
+ },
81
+ /** Retry button hover color */
82
+ retry_button_hover_color: {
83
+ type: ParameterType.STRING,
84
+ default: '#c82333',
85
+ },
86
+ /** Success message color */
87
+ success_color: {
88
+ type: ParameterType.STRING,
89
+ default: '#28a745',
90
+ },
91
+ /** Error message color */
92
+ error_color: {
93
+ type: ParameterType.STRING,
94
+ default: '#dc3545',
95
+ },
96
+ /** Normalized tolerance for acceptable accuracy (0-1 scale, validation passes if average error <= this) */
97
+ tolerance: {
98
+ type: ParameterType.FLOAT,
99
+ default: 0.05,
100
+ },
101
+ /** Maximum number of retry attempts allowed on validation failure */
102
+ max_retries: {
103
+ type: ParameterType.INT,
104
+ default: 1,
105
+ },
106
+ /** Duration of zoom in/out animations in ms */
107
+ zoom_duration: {
108
+ type: ParameterType.INT,
109
+ default: 300,
110
+ },
111
+ },
112
+ data: {
113
+ /** Validation success status */
114
+ validation_success: {
115
+ type: ParameterType.BOOL,
116
+ },
117
+ /** Average accuracy */
118
+ average_accuracy: {
119
+ type: ParameterType.FLOAT,
120
+ },
121
+ /** Average precision */
122
+ average_precision: {
123
+ type: ParameterType.FLOAT,
124
+ },
125
+ /** Number of validation points used */
126
+ num_points: {
127
+ type: ParameterType.INT,
128
+ },
129
+ /** Full validation result data */
130
+ validation_data: {
131
+ type: ParameterType.COMPLEX,
132
+ },
133
+ /** Normalized tolerance used for pass/fail determination */
134
+ tolerance: {
135
+ type: ParameterType.FLOAT,
136
+ },
137
+ /** Number of validation attempts made */
138
+ num_attempts: {
139
+ type: ParameterType.INT,
140
+ },
141
+ },
142
+ };
143
+
144
+ type Info = typeof info;
145
+
146
+ class TobiiValidationPlugin implements JsPsychPlugin<Info> {
147
+ static info = info;
148
+
149
+ constructor(private jsPsych: JsPsych) {}
150
+
151
+ private static removeStyles(): void {
152
+ const el = document.getElementById('tobii-validation-styles');
153
+ if (el) {
154
+ el.remove();
155
+ }
156
+ }
157
+
158
+ private injectStyles(trial: TrialType<Info>): void {
159
+ // Remove existing styles so each trial gets its own colors
160
+ TobiiValidationPlugin.removeStyles();
161
+
162
+ const css = `
163
+ .tobii-validation-container {
164
+ position: fixed;
165
+ top: 0;
166
+ left: 0;
167
+ width: 100%;
168
+ height: 100%;
169
+ background-color: ${trial.background_color};
170
+ font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
171
+ z-index: 9999;
172
+ }
173
+
174
+ .tobii-validation-instructions {
175
+ position: absolute;
176
+ top: 50%;
177
+ left: 50%;
178
+ transform: translate(-50%, -50%);
179
+ background-color: white;
180
+ padding: 40px;
181
+ border-radius: 10px;
182
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
183
+ text-align: center;
184
+ max-width: 600px;
185
+ }
186
+
187
+ .tobii-validation-instructions h2 {
188
+ margin-top: 0;
189
+ margin-bottom: 20px;
190
+ font-size: 24px;
191
+ color: #333;
192
+ }
193
+
194
+ .tobii-validation-instructions p {
195
+ margin-bottom: 20px;
196
+ font-size: 16px;
197
+ line-height: 1.5;
198
+ color: #666;
199
+ }
200
+
201
+ .validation-start-btn,
202
+ .validation-continue-btn,
203
+ .validation-retry-btn {
204
+ background-color: ${trial.button_color};
205
+ color: white;
206
+ border: none;
207
+ padding: 12px 30px;
208
+ font-size: 16px;
209
+ border-radius: 5px;
210
+ cursor: pointer;
211
+ transition: background-color 0.3s;
212
+ }
213
+
214
+ .validation-start-btn:hover,
215
+ .validation-continue-btn:hover {
216
+ background-color: ${trial.button_hover_color};
217
+ }
218
+
219
+ .validation-retry-btn {
220
+ background-color: ${trial.retry_button_color};
221
+ }
222
+
223
+ .validation-retry-btn:hover {
224
+ background-color: ${trial.retry_button_hover_color};
225
+ }
226
+
227
+ .tobii-validation-point {
228
+ position: absolute;
229
+ border-radius: 50%;
230
+ transform: translate(-50%, -50%);
231
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
232
+ }
233
+
234
+ .tobii-validation-point.animation-zoom-out {
235
+ animation: tobii-validation-zoom-out ${(trial.zoom_duration as number) / 1000}s ease-out forwards;
236
+ }
237
+
238
+ @keyframes tobii-validation-zoom-out {
239
+ 0% {
240
+ transform: translate(-50%, -50%) scale(1);
241
+ }
242
+ 100% {
243
+ transform: translate(-50%, -50%) scale(2.5);
244
+ }
245
+ }
246
+
247
+ .tobii-validation-point.animation-zoom-in {
248
+ animation: tobii-validation-zoom-in ${(trial.zoom_duration as number) / 1000}s ease-out forwards;
249
+ }
250
+
251
+ @keyframes tobii-validation-zoom-in {
252
+ 0% {
253
+ transform: translate(-50%, -50%) scale(2.5);
254
+ }
255
+ 100% {
256
+ transform: translate(-50%, -50%) scale(1);
257
+ }
258
+ }
259
+
260
+ .tobii-validation-progress {
261
+ position: fixed;
262
+ top: 20px;
263
+ left: 50%;
264
+ transform: translateX(-50%);
265
+ background-color: rgba(0, 0, 0, 0.7);
266
+ color: white;
267
+ padding: 10px 20px;
268
+ border-radius: 5px;
269
+ font-size: 14px;
270
+ z-index: 10000;
271
+ }
272
+
273
+ .tobii-validation-result {
274
+ position: absolute;
275
+ top: 50%;
276
+ left: 50%;
277
+ transform: translate(-50%, -50%);
278
+ background-color: white;
279
+ padding: 30px 40px;
280
+ border-radius: 10px;
281
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
282
+ text-align: center;
283
+ width: 60vw;
284
+ max-width: 800px;
285
+ max-height: 85vh;
286
+ overflow-y: auto;
287
+ }
288
+
289
+ .tobii-validation-result-content h2 {
290
+ margin-top: 0;
291
+ margin-bottom: 20px;
292
+ font-size: 24px;
293
+ }
294
+
295
+ .tobii-validation-result-content.success h2 {
296
+ color: ${trial.success_color};
297
+ }
298
+
299
+ .tobii-validation-result-content.error h2 {
300
+ color: ${trial.error_color};
301
+ }
302
+
303
+ .tobii-validation-result-content p {
304
+ margin-bottom: 15px;
305
+ font-size: 16px;
306
+ color: #666;
307
+ }
308
+
309
+ .validation-feedback {
310
+ margin: 30px 0;
311
+ }
312
+
313
+ .validation-feedback h3 {
314
+ margin-bottom: 15px;
315
+ font-size: 18px;
316
+ color: #333;
317
+ }
318
+
319
+ .feedback-canvas {
320
+ position: relative;
321
+ width: 100%;
322
+ height: 300px;
323
+ background-color: #f0f0f0;
324
+ border: 2px solid #ddd;
325
+ border-radius: 5px;
326
+ margin-bottom: 15px;
327
+ }
328
+
329
+ .feedback-point {
330
+ position: absolute;
331
+ width: 20px;
332
+ height: 20px;
333
+ border-radius: 50%;
334
+ transform: translate(-50%, -50%);
335
+ border: 2px solid #333;
336
+ cursor: help;
337
+ }
338
+
339
+ .feedback-legend {
340
+ display: flex;
341
+ justify-content: center;
342
+ gap: 20px;
343
+ font-size: 14px;
344
+ color: #666;
345
+ }
346
+
347
+ .feedback-legend span {
348
+ display: flex;
349
+ align-items: center;
350
+ gap: 5px;
351
+ }
352
+
353
+ .legend-color {
354
+ display: inline-block;
355
+ width: 15px;
356
+ height: 15px;
357
+ border-radius: 50%;
358
+ border: 1px solid #333;
359
+ }
360
+
361
+ .target-legend {
362
+ background-color: transparent;
363
+ border: 3px solid #333;
364
+ }
365
+
366
+ .feedback-canvas-fullscreen {
367
+ position: relative;
368
+ width: 100%;
369
+ background-color: #2a2a2a;
370
+ border: 2px solid #444;
371
+ border-radius: 5px;
372
+ margin-bottom: 15px;
373
+ overflow: hidden;
374
+ }
375
+
376
+ .feedback-target {
377
+ position: absolute;
378
+ width: 24px;
379
+ height: 24px;
380
+ border-radius: 50%;
381
+ transform: translate(-50%, -50%);
382
+ border: 3px solid #fff;
383
+ background-color: transparent;
384
+ z-index: 10;
385
+ display: flex;
386
+ align-items: center;
387
+ justify-content: center;
388
+ }
389
+
390
+ .target-label {
391
+ color: #fff;
392
+ font-size: 10px;
393
+ font-weight: bold;
394
+ }
395
+
396
+ .feedback-gaze {
397
+ position: absolute;
398
+ width: 20px;
399
+ height: 20px;
400
+ border-radius: 50%;
401
+ transform: translate(-50%, -50%);
402
+ border: 2px solid;
403
+ z-index: 11;
404
+ display: flex;
405
+ align-items: center;
406
+ justify-content: center;
407
+ cursor: help;
408
+ }
409
+
410
+ .gaze-label {
411
+ color: #000;
412
+ font-size: 9px;
413
+ font-weight: bold;
414
+ }
415
+
416
+ .feedback-sample {
417
+ position: absolute;
418
+ width: 4px;
419
+ height: 4px;
420
+ border-radius: 50%;
421
+ transform: translate(-50%, -50%);
422
+ background-color: rgba(100, 100, 255, 0.4);
423
+ z-index: 5;
424
+ }
425
+
426
+ .accuracy-table {
427
+ width: 100%;
428
+ border-collapse: collapse;
429
+ margin-top: 15px;
430
+ font-size: 13px;
431
+ text-align: left;
432
+ }
433
+
434
+ .accuracy-table th,
435
+ .accuracy-table td {
436
+ padding: 8px 12px;
437
+ border: 1px solid #ddd;
438
+ }
439
+
440
+ .accuracy-table th {
441
+ background-color: #f5f5f5;
442
+ font-weight: 600;
443
+ color: #333;
444
+ }
445
+
446
+ .accuracy-table tr:nth-child(even) {
447
+ background-color: #fafafa;
448
+ }
449
+
450
+ .accuracy-table td {
451
+ color: #555;
452
+ }
453
+
454
+ .saccade-note {
455
+ font-size: 12px;
456
+ color: #888;
457
+ font-style: italic;
458
+ margin-top: 10px;
459
+ }
460
+
461
+ .tolerance-info {
462
+ font-size: 14px;
463
+ color: #666;
464
+ }
465
+
466
+ .gaze-pass-legend {
467
+ background-color: #4ade80;
468
+ }
469
+
470
+ .gaze-fail-legend {
471
+ background-color: #f87171;
472
+ }
473
+
474
+ .feedback-gaze.gaze-pass {
475
+ background-color: #4ade80;
476
+ border-color: #22c55e;
477
+ }
478
+
479
+ .feedback-gaze.gaze-fail {
480
+ background-color: #f87171;
481
+ border-color: #ef4444;
482
+ }
483
+
484
+ .feedback-sample.sample-pass {
485
+ background-color: rgba(74, 222, 128, 0.5);
486
+ }
487
+
488
+ .feedback-sample.sample-fail {
489
+ background-color: rgba(248, 113, 113, 0.5);
490
+ }
491
+ `;
492
+
493
+ const styleElement = document.createElement('style');
494
+ styleElement.id = 'tobii-validation-styles';
495
+ styleElement.textContent = css;
496
+ document.head.appendChild(styleElement);
497
+ }
498
+
499
+ async trial(display_element: HTMLElement, trial: TrialType<Info>): Promise<void> {
500
+ // Inject styles
501
+ this.injectStyles(trial);
502
+ // Get extension instance
503
+ const tobiiExt = this.jsPsych.extensions.tobii as unknown as TobiiExtension;
504
+
505
+ if (!tobiiExt) {
506
+ throw new Error('Tobii extension not initialized');
507
+ }
508
+
509
+ // Check connection
510
+ if (!tobiiExt.isConnected()) {
511
+ throw new Error('Not connected to Tobii server');
512
+ }
513
+
514
+ // Create validation display
515
+ const validationDisplay = new ValidationDisplay(
516
+ display_element,
517
+ trial as unknown as ValidationParameters
518
+ );
519
+
520
+ // Show instructions (only once, before retry loop)
521
+ await validationDisplay.showInstructions();
522
+
523
+ // Get validation points and validate custom points
524
+ let points: ValidationPoint[];
525
+ if (trial.custom_points) {
526
+ points = this.validateCustomPoints(trial.custom_points);
527
+ } else {
528
+ points = this.getValidationPoints(trial.validation_points!);
529
+ }
530
+
531
+ const maxAttempts = 1 + (trial.max_retries as number);
532
+ let attempt = 0;
533
+ let validationPassed = false;
534
+ let avgAccuracyNorm = 0;
535
+ let avgPrecisionNorm = 0;
536
+ let validationResult: ValidationResult = { success: false };
537
+
538
+ try {
539
+ // Retry loop
540
+ while (attempt < maxAttempts) {
541
+ attempt++;
542
+ const retriesRemaining = maxAttempts - attempt;
543
+
544
+ // Start validation on server (resets server-side state on each call)
545
+ await tobiiExt.startValidation();
546
+
547
+ // Start tracking to collect gaze data
548
+ await tobiiExt.startTracking();
549
+
550
+ // Initialize point at screen center (with brief pause)
551
+ await validationDisplay.initializePoint();
552
+
553
+ // Show each point and collect validation data with smooth path animation
554
+ for (let i = 0; i < points.length; i++) {
555
+ const point = points[i];
556
+
557
+ // Travel to the point location (smooth animation from current position)
558
+ await validationDisplay.travelToPoint(point, i, points.length);
559
+
560
+ // Zoom out (point grows larger to attract attention)
561
+ await validationDisplay.playZoomOut();
562
+
563
+ // Zoom in (point shrinks to fixation size)
564
+ await validationDisplay.playZoomIn();
565
+
566
+ // Capture start time before collection period for precise time-range query
567
+ const collectionStartTime = performance.now();
568
+
569
+ // Wait for data collection
570
+ await this.delay(trial.collection_duration!);
571
+
572
+ // Capture end time after collection period
573
+ const collectionEndTime = performance.now();
574
+
575
+ // Get gaze samples collected during exactly this point's display period
576
+ const gazeSamples = await tobiiExt.getGazeData(collectionStartTime, collectionEndTime);
577
+
578
+ // Collect validation data for this point with the gaze samples
579
+ await tobiiExt.collectValidationPoint(point.x, point.y, gazeSamples);
580
+
581
+ // Reset point for next travel (don't remove element)
582
+ if (i < points.length - 1) {
583
+ await validationDisplay.resetPointForTravel();
584
+ }
585
+ }
586
+
587
+ // Hide point after final data collection
588
+ await validationDisplay.hidePoint();
589
+
590
+ // Stop tracking
591
+ await tobiiExt.stopTracking();
592
+
593
+ // Compute validation on server
594
+ validationResult = await tobiiExt.computeValidation();
595
+
596
+ // Get normalized accuracy values from server
597
+ avgAccuracyNorm = validationResult.averageAccuracyNorm || 0;
598
+ avgPrecisionNorm = validationResult.averagePrecisionNorm || 0;
599
+
600
+ // Determine if validation passes based on normalized tolerance
601
+ validationPassed = validationResult.success && avgAccuracyNorm <= trial.tolerance!;
602
+
603
+ // Show result with retry option if retries remain
604
+ const userChoice = await validationDisplay.showResult(
605
+ validationPassed,
606
+ avgAccuracyNorm,
607
+ avgPrecisionNorm,
608
+ validationResult.pointData || [],
609
+ trial.tolerance,
610
+ retriesRemaining > 0
611
+ );
612
+
613
+ if (userChoice === 'continue') {
614
+ break;
615
+ }
616
+
617
+ // User chose retry — reset display for next attempt
618
+ validationDisplay.resetForRetry();
619
+ }
620
+ } finally {
621
+ // Clear display and remove injected styles
622
+ validationDisplay.clear();
623
+ display_element.innerHTML = '';
624
+ TobiiValidationPlugin.removeStyles();
625
+ }
626
+
627
+ // Finish trial
628
+ const trial_data = {
629
+ validation_success: validationPassed,
630
+ average_accuracy: avgAccuracyNorm,
631
+ average_precision: avgPrecisionNorm,
632
+ tolerance: trial.tolerance,
633
+ num_points: points.length,
634
+ validation_data: validationResult,
635
+ num_attempts: attempt,
636
+ };
637
+
638
+ this.jsPsych.finishTrial(trial_data);
639
+ }
640
+
641
+ /**
642
+ * Validate custom validation points
643
+ */
644
+ private validateCustomPoints(points: unknown[]): ValidationPoint[] {
645
+ if (!Array.isArray(points) || points.length === 0) {
646
+ throw new Error('custom_points must be a non-empty array');
647
+ }
648
+
649
+ const validated: ValidationPoint[] = [];
650
+ for (let i = 0; i < points.length; i++) {
651
+ const point = points[i] as Record<string, unknown>;
652
+ if (
653
+ typeof point !== 'object' ||
654
+ point === null ||
655
+ typeof point.x !== 'number' ||
656
+ typeof point.y !== 'number'
657
+ ) {
658
+ throw new Error(`Invalid validation point at index ${i}: must have numeric x and y`);
659
+ }
660
+ if (point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1) {
661
+ throw new Error(
662
+ `Validation point at index ${i} out of range: x and y must be between 0 and 1`
663
+ );
664
+ }
665
+ validated.push({ x: point.x, y: point.y });
666
+ }
667
+
668
+ return validated;
669
+ }
670
+
671
+ /**
672
+ * Get standard validation points for the given grid size
673
+ */
674
+ private getValidationPoints(count: number): ValidationPoint[] {
675
+ switch (count) {
676
+ case 5:
677
+ return [
678
+ { x: 0.1, y: 0.1 },
679
+ { x: 0.9, y: 0.1 },
680
+ { x: 0.5, y: 0.5 },
681
+ { x: 0.1, y: 0.9 },
682
+ { x: 0.9, y: 0.9 },
683
+ ];
684
+ case 9:
685
+ return [
686
+ { x: 0.1, y: 0.1 },
687
+ { x: 0.5, y: 0.1 },
688
+ { x: 0.9, y: 0.1 },
689
+ { x: 0.1, y: 0.5 },
690
+ { x: 0.5, y: 0.5 },
691
+ { x: 0.9, y: 0.5 },
692
+ { x: 0.1, y: 0.9 },
693
+ { x: 0.5, y: 0.9 },
694
+ { x: 0.9, y: 0.9 },
695
+ ];
696
+ case 13:
697
+ // 3x3 outer grid + 4 diagonal midpoints
698
+ return [
699
+ { x: 0.1, y: 0.1 },
700
+ { x: 0.5, y: 0.1 },
701
+ { x: 0.9, y: 0.1 },
702
+ { x: 0.3, y: 0.3 },
703
+ { x: 0.7, y: 0.3 },
704
+ { x: 0.1, y: 0.5 },
705
+ { x: 0.5, y: 0.5 },
706
+ { x: 0.9, y: 0.5 },
707
+ { x: 0.3, y: 0.7 },
708
+ { x: 0.7, y: 0.7 },
709
+ { x: 0.1, y: 0.9 },
710
+ { x: 0.5, y: 0.9 },
711
+ { x: 0.9, y: 0.9 },
712
+ ];
713
+ case 15:
714
+ // 5 rows x 3 columns
715
+ return [
716
+ { x: 0.1, y: 0.1 },
717
+ { x: 0.5, y: 0.1 },
718
+ { x: 0.9, y: 0.1 },
719
+ { x: 0.1, y: 0.3 },
720
+ { x: 0.5, y: 0.3 },
721
+ { x: 0.9, y: 0.3 },
722
+ { x: 0.1, y: 0.5 },
723
+ { x: 0.5, y: 0.5 },
724
+ { x: 0.9, y: 0.5 },
725
+ { x: 0.1, y: 0.7 },
726
+ { x: 0.5, y: 0.7 },
727
+ { x: 0.9, y: 0.7 },
728
+ { x: 0.1, y: 0.9 },
729
+ { x: 0.5, y: 0.9 },
730
+ { x: 0.9, y: 0.9 },
731
+ ];
732
+ case 19:
733
+ // Symmetric 3-5-3-5-3 pattern
734
+ return [
735
+ { x: 0.1, y: 0.1 },
736
+ { x: 0.5, y: 0.1 },
737
+ { x: 0.9, y: 0.1 },
738
+ { x: 0.1, y: 0.3 },
739
+ { x: 0.3, y: 0.3 },
740
+ { x: 0.5, y: 0.3 },
741
+ { x: 0.7, y: 0.3 },
742
+ { x: 0.9, y: 0.3 },
743
+ { x: 0.1, y: 0.5 },
744
+ { x: 0.5, y: 0.5 },
745
+ { x: 0.9, y: 0.5 },
746
+ { x: 0.1, y: 0.7 },
747
+ { x: 0.3, y: 0.7 },
748
+ { x: 0.5, y: 0.7 },
749
+ { x: 0.7, y: 0.7 },
750
+ { x: 0.9, y: 0.7 },
751
+ { x: 0.1, y: 0.9 },
752
+ { x: 0.5, y: 0.9 },
753
+ { x: 0.9, y: 0.9 },
754
+ ];
755
+ case 25:
756
+ // 5x5 full grid
757
+ return [
758
+ { x: 0.1, y: 0.1 },
759
+ { x: 0.3, y: 0.1 },
760
+ { x: 0.5, y: 0.1 },
761
+ { x: 0.7, y: 0.1 },
762
+ { x: 0.9, y: 0.1 },
763
+ { x: 0.1, y: 0.3 },
764
+ { x: 0.3, y: 0.3 },
765
+ { x: 0.5, y: 0.3 },
766
+ { x: 0.7, y: 0.3 },
767
+ { x: 0.9, y: 0.3 },
768
+ { x: 0.1, y: 0.5 },
769
+ { x: 0.3, y: 0.5 },
770
+ { x: 0.5, y: 0.5 },
771
+ { x: 0.7, y: 0.5 },
772
+ { x: 0.9, y: 0.5 },
773
+ { x: 0.1, y: 0.7 },
774
+ { x: 0.3, y: 0.7 },
775
+ { x: 0.5, y: 0.7 },
776
+ { x: 0.7, y: 0.7 },
777
+ { x: 0.9, y: 0.7 },
778
+ { x: 0.1, y: 0.9 },
779
+ { x: 0.3, y: 0.9 },
780
+ { x: 0.5, y: 0.9 },
781
+ { x: 0.7, y: 0.9 },
782
+ { x: 0.9, y: 0.9 },
783
+ ];
784
+ default:
785
+ throw new Error(
786
+ `Unsupported validation_points value: ${count}. Use 5, 9, 13, 15, 19, or 25, or provide custom_points.`
787
+ );
788
+ }
789
+ }
790
+
791
+ /**
792
+ * Delay helper
793
+ */
794
+ private delay(ms: number): Promise<void> {
795
+ return new Promise((resolve) => setTimeout(resolve, ms));
796
+ }
797
+ }
798
+
799
+ export default TobiiValidationPlugin;