@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/README.md +170 -0
- package/dist/index.browser.js +1003 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.min.js +373 -0
- package/dist/index.browser.min.js.map +1 -0
- package/dist/index.cjs +1002 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +286 -0
- package/dist/index.js +1000 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/index.d.ts +283 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +738 -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 +116 -0
- package/src/index.spec.js.map +1 -0
- package/src/index.spec.ts +145 -0
- package/src/index.ts +799 -0
- package/src/types.d.ts +63 -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 +63 -0
- package/src/validation-display.d.ts +57 -0
- package/src/validation-display.d.ts.map +1 -0
- package/src/validation-display.js +324 -0
- package/src/validation-display.js.map +1 -0
- package/src/validation-display.ts +385 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jspsych = require('jspsych');
|
|
4
|
+
|
|
5
|
+
var version = "0.1.1";
|
|
6
|
+
|
|
7
|
+
class ValidationDisplay {
|
|
8
|
+
constructor(displayElement, params) {
|
|
9
|
+
this.displayElement = displayElement;
|
|
10
|
+
this.params = params;
|
|
11
|
+
this.currentPoint = null;
|
|
12
|
+
this.progressElement = null;
|
|
13
|
+
this.currentX = 0.5;
|
|
14
|
+
this.currentY = 0.5;
|
|
15
|
+
this.container = this.createContainer();
|
|
16
|
+
this.displayElement.appendChild(this.container);
|
|
17
|
+
if (params.show_progress) {
|
|
18
|
+
this.progressElement = this.createProgressIndicator();
|
|
19
|
+
this.displayElement.appendChild(this.progressElement);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
createContainer() {
|
|
23
|
+
const container = document.createElement("div");
|
|
24
|
+
container.className = "tobii-validation-container";
|
|
25
|
+
return container;
|
|
26
|
+
}
|
|
27
|
+
createProgressIndicator() {
|
|
28
|
+
const progress = document.createElement("div");
|
|
29
|
+
progress.className = "tobii-validation-progress";
|
|
30
|
+
progress.setAttribute("role", "status");
|
|
31
|
+
progress.setAttribute("aria-live", "polite");
|
|
32
|
+
return progress;
|
|
33
|
+
}
|
|
34
|
+
async showInstructions() {
|
|
35
|
+
const wrapper = document.createElement("div");
|
|
36
|
+
wrapper.className = "tobii-validation-instructions";
|
|
37
|
+
wrapper.setAttribute("role", "dialog");
|
|
38
|
+
wrapper.setAttribute("aria-label", "Eye tracker validation instructions");
|
|
39
|
+
const content = document.createElement("div");
|
|
40
|
+
content.className = "instructions-content";
|
|
41
|
+
const heading = document.createElement("h2");
|
|
42
|
+
heading.textContent = "Eye Tracker Validation";
|
|
43
|
+
content.appendChild(heading);
|
|
44
|
+
const paragraph = document.createElement("p");
|
|
45
|
+
paragraph.innerHTML = this.params.instructions || "Look at each point to validate calibration accuracy.";
|
|
46
|
+
content.appendChild(paragraph);
|
|
47
|
+
const button = document.createElement("button");
|
|
48
|
+
button.className = "validation-start-btn";
|
|
49
|
+
button.textContent = "Start Validation";
|
|
50
|
+
content.appendChild(button);
|
|
51
|
+
wrapper.appendChild(content);
|
|
52
|
+
this.container.appendChild(wrapper);
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
button.addEventListener("click", () => {
|
|
55
|
+
wrapper.remove();
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Initialize the traveling point at screen center
|
|
62
|
+
*/
|
|
63
|
+
async initializePoint() {
|
|
64
|
+
if (this.currentPoint)
|
|
65
|
+
return;
|
|
66
|
+
this.currentPoint = document.createElement("div");
|
|
67
|
+
this.currentPoint.className = "tobii-validation-point";
|
|
68
|
+
this.currentPoint.setAttribute("role", "img");
|
|
69
|
+
this.currentPoint.setAttribute("aria-label", "Validation target point");
|
|
70
|
+
const x = 0.5 * window.innerWidth;
|
|
71
|
+
const y = 0.5 * window.innerHeight;
|
|
72
|
+
this.currentX = 0.5;
|
|
73
|
+
this.currentY = 0.5;
|
|
74
|
+
Object.assign(this.currentPoint.style, {
|
|
75
|
+
left: `${x}px`,
|
|
76
|
+
top: `${y}px`,
|
|
77
|
+
width: `${this.params.point_size || 20}px`,
|
|
78
|
+
height: `${this.params.point_size || 20}px`,
|
|
79
|
+
backgroundColor: this.params.point_color || "#00ff00",
|
|
80
|
+
transition: "none"
|
|
81
|
+
});
|
|
82
|
+
this.container.appendChild(this.currentPoint);
|
|
83
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Travel to the next point location with smooth animation
|
|
87
|
+
*/
|
|
88
|
+
async travelToPoint(point, index, total) {
|
|
89
|
+
if (!this.currentPoint) {
|
|
90
|
+
await this.initializePoint();
|
|
91
|
+
}
|
|
92
|
+
if (this.progressElement) {
|
|
93
|
+
this.progressElement.textContent = `Point ${index + 1} of ${total}`;
|
|
94
|
+
}
|
|
95
|
+
this.currentPoint.setAttribute("aria-label", `Validation target point ${index + 1} of ${total}`);
|
|
96
|
+
const dx = point.x - this.currentX;
|
|
97
|
+
const dy = point.y - this.currentY;
|
|
98
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
99
|
+
const travelDuration = Math.max(150, Math.min(400, 150 + distance * 200));
|
|
100
|
+
const x = point.x * window.innerWidth;
|
|
101
|
+
const y = point.y * window.innerHeight;
|
|
102
|
+
this.currentPoint.style.transition = `left ${travelDuration}ms ease-in-out, top ${travelDuration}ms ease-in-out`;
|
|
103
|
+
this.currentPoint.classList.remove("animation-zoom-out", "animation-zoom-in");
|
|
104
|
+
this.currentPoint.style.left = `${x}px`;
|
|
105
|
+
this.currentPoint.style.top = `${y}px`;
|
|
106
|
+
this.currentX = point.x;
|
|
107
|
+
this.currentY = point.y;
|
|
108
|
+
await this.delay(travelDuration);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Play zoom out animation (point grows larger)
|
|
112
|
+
*/
|
|
113
|
+
async playZoomOut() {
|
|
114
|
+
if (!this.currentPoint)
|
|
115
|
+
return;
|
|
116
|
+
this.currentPoint.style.transition = "none";
|
|
117
|
+
this.currentPoint.classList.remove("animation-zoom-in");
|
|
118
|
+
this.currentPoint.classList.add("animation-zoom-out");
|
|
119
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Play zoom in animation (point shrinks to fixation size)
|
|
123
|
+
*/
|
|
124
|
+
async playZoomIn() {
|
|
125
|
+
if (!this.currentPoint)
|
|
126
|
+
return;
|
|
127
|
+
this.currentPoint.classList.remove("animation-zoom-out");
|
|
128
|
+
this.currentPoint.classList.add("animation-zoom-in");
|
|
129
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Reset point state after data collection (keeps element for continued travel)
|
|
133
|
+
*/
|
|
134
|
+
async resetPointForTravel() {
|
|
135
|
+
if (!this.currentPoint)
|
|
136
|
+
return;
|
|
137
|
+
this.currentPoint.classList.remove("animation-zoom-out", "animation-zoom-in");
|
|
138
|
+
this.currentPoint.style.transform = "translate(-50%, -50%) scale(1)";
|
|
139
|
+
await this.delay(50);
|
|
140
|
+
}
|
|
141
|
+
async hidePoint() {
|
|
142
|
+
if (this.currentPoint) {
|
|
143
|
+
this.currentPoint.remove();
|
|
144
|
+
this.currentPoint = null;
|
|
145
|
+
}
|
|
146
|
+
await this.delay(200);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Show validation result
|
|
150
|
+
* @param success Whether validation passed
|
|
151
|
+
* @param averageAccuracyNorm Average accuracy in normalized units
|
|
152
|
+
* @param averagePrecisionNorm Average precision in normalized units
|
|
153
|
+
* @param pointData Per-point validation data
|
|
154
|
+
* @param tolerance Tolerance threshold
|
|
155
|
+
* @param canRetry Whether a retry button should be shown on failure
|
|
156
|
+
* @returns 'retry' if user chose to retry, 'continue' otherwise
|
|
157
|
+
*/
|
|
158
|
+
async showResult(success, averageAccuracyNorm, _averagePrecisionNorm, pointData, tolerance, canRetry = false) {
|
|
159
|
+
const result = document.createElement("div");
|
|
160
|
+
result.className = "tobii-validation-result";
|
|
161
|
+
result.setAttribute("role", "alert");
|
|
162
|
+
result.setAttribute("aria-live", "assertive");
|
|
163
|
+
let feedbackHTML = "";
|
|
164
|
+
if (this.params.show_feedback && pointData) {
|
|
165
|
+
feedbackHTML = this.createVisualFeedback(pointData, tolerance);
|
|
166
|
+
}
|
|
167
|
+
const statusClass = success ? "success" : "error";
|
|
168
|
+
const statusText = success ? "Validation Passed" : "Validation Failed";
|
|
169
|
+
let buttonsHTML;
|
|
170
|
+
if (success) {
|
|
171
|
+
buttonsHTML = `<button class="validation-continue-btn">Continue</button>`;
|
|
172
|
+
} else if (canRetry) {
|
|
173
|
+
buttonsHTML = `<button class="validation-retry-btn">Retry</button>
|
|
174
|
+
<button class="validation-continue-btn" style="margin-left: 10px;">Continue</button>`;
|
|
175
|
+
} else {
|
|
176
|
+
buttonsHTML = `<button class="validation-continue-btn">Continue</button>`;
|
|
177
|
+
}
|
|
178
|
+
result.innerHTML = `
|
|
179
|
+
<div class="tobii-validation-result-content ${statusClass}">
|
|
180
|
+
<h2>${statusText}</h2>
|
|
181
|
+
<p>Average error: ${((averageAccuracyNorm || 0) * 100).toFixed(1)}% (tolerance: ${((tolerance || 0) * 100).toFixed(0)}%)</p>
|
|
182
|
+
${feedbackHTML}
|
|
183
|
+
${buttonsHTML}
|
|
184
|
+
</div>
|
|
185
|
+
`;
|
|
186
|
+
this.container.appendChild(result);
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
const retryBtn = result.querySelector(".validation-retry-btn");
|
|
189
|
+
const continueBtn = result.querySelector(".validation-continue-btn");
|
|
190
|
+
retryBtn?.addEventListener("click", () => {
|
|
191
|
+
result.remove();
|
|
192
|
+
resolve("retry");
|
|
193
|
+
});
|
|
194
|
+
continueBtn?.addEventListener("click", () => {
|
|
195
|
+
result.remove();
|
|
196
|
+
resolve("continue");
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Reset display state for a retry attempt
|
|
202
|
+
*/
|
|
203
|
+
resetForRetry() {
|
|
204
|
+
this.container.innerHTML = "";
|
|
205
|
+
this.currentPoint = null;
|
|
206
|
+
this.currentX = 0.5;
|
|
207
|
+
this.currentY = 0.5;
|
|
208
|
+
}
|
|
209
|
+
createVisualFeedback(pointData, tolerance) {
|
|
210
|
+
const tol = tolerance || 0.05;
|
|
211
|
+
const targetMarkers = pointData.map((data, idx) => {
|
|
212
|
+
const x = data.point.x * 100;
|
|
213
|
+
const y = data.point.y * 100;
|
|
214
|
+
return `
|
|
215
|
+
<div class="feedback-target" style="
|
|
216
|
+
left: ${x}%;
|
|
217
|
+
top: ${y}%;
|
|
218
|
+
" title="Target ${idx + 1}">
|
|
219
|
+
<span class="target-label">${idx + 1}</span>
|
|
220
|
+
</div>
|
|
221
|
+
`;
|
|
222
|
+
}).join("");
|
|
223
|
+
const gazeMarkers = pointData.map((data, idx) => {
|
|
224
|
+
if (!data.meanGaze)
|
|
225
|
+
return "";
|
|
226
|
+
const x = data.meanGaze.x * 100;
|
|
227
|
+
const y = data.meanGaze.y * 100;
|
|
228
|
+
const withinTolerance = data.accuracyNorm <= tol;
|
|
229
|
+
const colorClass = withinTolerance ? "gaze-pass" : "gaze-fail";
|
|
230
|
+
const statusSymbol = withinTolerance ? "\u2713" : "\u2717";
|
|
231
|
+
const statusLabel = withinTolerance ? "pass" : "fail";
|
|
232
|
+
return `
|
|
233
|
+
<div class="feedback-gaze ${colorClass}" style="
|
|
234
|
+
left: ${x}%;
|
|
235
|
+
top: ${y}%;
|
|
236
|
+
" title="Point ${idx + 1}: ${statusLabel}, error ${(data.accuracyNorm * 100).toFixed(1)}%"
|
|
237
|
+
aria-label="Point ${idx + 1}: ${statusLabel}, error ${(data.accuracyNorm * 100).toFixed(1)}%">
|
|
238
|
+
<span class="gaze-label">${statusSymbol}</span>
|
|
239
|
+
</div>
|
|
240
|
+
`;
|
|
241
|
+
}).join("");
|
|
242
|
+
const connectionLines = pointData.map((data) => {
|
|
243
|
+
if (!data.meanGaze)
|
|
244
|
+
return "";
|
|
245
|
+
const x1 = data.point.x * 100;
|
|
246
|
+
const y1 = data.point.y * 100;
|
|
247
|
+
const x2 = data.meanGaze.x * 100;
|
|
248
|
+
const y2 = data.meanGaze.y * 100;
|
|
249
|
+
const withinTolerance = data.accuracyNorm <= tol;
|
|
250
|
+
const lineColor = withinTolerance ? "#4ade80" : "#f87171";
|
|
251
|
+
return `
|
|
252
|
+
<svg class="feedback-line" style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none;">
|
|
253
|
+
<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%"
|
|
254
|
+
stroke="${lineColor}" stroke-width="2" stroke-dasharray="5,3" opacity="0.7"/>
|
|
255
|
+
</svg>
|
|
256
|
+
`;
|
|
257
|
+
}).join("");
|
|
258
|
+
const gazeSampleDots = pointData.map((data) => {
|
|
259
|
+
if (!data.gazeSamples)
|
|
260
|
+
return "";
|
|
261
|
+
const withinTolerance = data.accuracyNorm <= tol;
|
|
262
|
+
const sampleClass = withinTolerance ? "sample-pass" : "sample-fail";
|
|
263
|
+
return data.gazeSamples.map((sample) => {
|
|
264
|
+
const x = sample.x * 100;
|
|
265
|
+
const y = sample.y * 100;
|
|
266
|
+
return `<div class="feedback-sample ${sampleClass}" style="left: ${x}%; top: ${y}%;"></div>`;
|
|
267
|
+
}).join("");
|
|
268
|
+
}).join("");
|
|
269
|
+
const aspectRatio = window.innerWidth / window.innerHeight;
|
|
270
|
+
const canvas = `
|
|
271
|
+
<div class="validation-feedback">
|
|
272
|
+
<div class="feedback-canvas-fullscreen" style="aspect-ratio: ${aspectRatio.toFixed(3)};">
|
|
273
|
+
${connectionLines}
|
|
274
|
+
${gazeSampleDots}
|
|
275
|
+
${targetMarkers}
|
|
276
|
+
${gazeMarkers}
|
|
277
|
+
</div>
|
|
278
|
+
<div class="feedback-legend">
|
|
279
|
+
<span><span class="legend-color target-legend"></span> Target</span>
|
|
280
|
+
<span><span class="legend-color gaze-pass-legend"></span> Pass (\u2713)</span>
|
|
281
|
+
<span><span class="legend-color gaze-fail-legend"></span> Fail (\u2717)</span>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
`;
|
|
285
|
+
return canvas;
|
|
286
|
+
}
|
|
287
|
+
clear() {
|
|
288
|
+
this.container.innerHTML = "";
|
|
289
|
+
if (this.progressElement) {
|
|
290
|
+
this.progressElement.textContent = "";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
delay(ms) {
|
|
294
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const info = {
|
|
299
|
+
name: "tobii-validation",
|
|
300
|
+
version,
|
|
301
|
+
parameters: {
|
|
302
|
+
/** Number of validation points (5 or 9) */
|
|
303
|
+
validation_points: {
|
|
304
|
+
type: jspsych.ParameterType.INT,
|
|
305
|
+
default: 9
|
|
306
|
+
},
|
|
307
|
+
/** Size of validation points in pixels */
|
|
308
|
+
point_size: {
|
|
309
|
+
type: jspsych.ParameterType.INT,
|
|
310
|
+
default: 20
|
|
311
|
+
},
|
|
312
|
+
/** Color of validation points */
|
|
313
|
+
point_color: {
|
|
314
|
+
type: jspsych.ParameterType.STRING,
|
|
315
|
+
default: "#00ff00"
|
|
316
|
+
},
|
|
317
|
+
/** Duration to collect data at each point (ms) */
|
|
318
|
+
collection_duration: {
|
|
319
|
+
type: jspsych.ParameterType.INT,
|
|
320
|
+
default: 1e3
|
|
321
|
+
},
|
|
322
|
+
/** Show progress indicator */
|
|
323
|
+
show_progress: {
|
|
324
|
+
type: jspsych.ParameterType.BOOL,
|
|
325
|
+
default: true
|
|
326
|
+
},
|
|
327
|
+
/** Custom validation points */
|
|
328
|
+
custom_points: {
|
|
329
|
+
type: jspsych.ParameterType.COMPLEX,
|
|
330
|
+
default: null
|
|
331
|
+
},
|
|
332
|
+
/** Show visual feedback */
|
|
333
|
+
show_feedback: {
|
|
334
|
+
type: jspsych.ParameterType.BOOL,
|
|
335
|
+
default: true
|
|
336
|
+
},
|
|
337
|
+
/** Instructions text */
|
|
338
|
+
instructions: {
|
|
339
|
+
type: jspsych.ParameterType.STRING,
|
|
340
|
+
default: "Look at each point as it appears on the screen to validate calibration accuracy."
|
|
341
|
+
},
|
|
342
|
+
/** Background color of the validation container */
|
|
343
|
+
background_color: {
|
|
344
|
+
type: jspsych.ParameterType.STRING,
|
|
345
|
+
default: "#808080"
|
|
346
|
+
},
|
|
347
|
+
/** Primary button color */
|
|
348
|
+
button_color: {
|
|
349
|
+
type: jspsych.ParameterType.STRING,
|
|
350
|
+
default: "#28a745"
|
|
351
|
+
},
|
|
352
|
+
/** Primary button hover color */
|
|
353
|
+
button_hover_color: {
|
|
354
|
+
type: jspsych.ParameterType.STRING,
|
|
355
|
+
default: "#218838"
|
|
356
|
+
},
|
|
357
|
+
/** Retry button color */
|
|
358
|
+
retry_button_color: {
|
|
359
|
+
type: jspsych.ParameterType.STRING,
|
|
360
|
+
default: "#dc3545"
|
|
361
|
+
},
|
|
362
|
+
/** Retry button hover color */
|
|
363
|
+
retry_button_hover_color: {
|
|
364
|
+
type: jspsych.ParameterType.STRING,
|
|
365
|
+
default: "#c82333"
|
|
366
|
+
},
|
|
367
|
+
/** Success message color */
|
|
368
|
+
success_color: {
|
|
369
|
+
type: jspsych.ParameterType.STRING,
|
|
370
|
+
default: "#28a745"
|
|
371
|
+
},
|
|
372
|
+
/** Error message color */
|
|
373
|
+
error_color: {
|
|
374
|
+
type: jspsych.ParameterType.STRING,
|
|
375
|
+
default: "#dc3545"
|
|
376
|
+
},
|
|
377
|
+
/** Normalized tolerance for acceptable accuracy (0-1 scale, validation passes if average error <= this) */
|
|
378
|
+
tolerance: {
|
|
379
|
+
type: jspsych.ParameterType.FLOAT,
|
|
380
|
+
default: 0.05
|
|
381
|
+
},
|
|
382
|
+
/** Maximum number of retry attempts allowed on validation failure */
|
|
383
|
+
max_retries: {
|
|
384
|
+
type: jspsych.ParameterType.INT,
|
|
385
|
+
default: 1
|
|
386
|
+
},
|
|
387
|
+
/** Duration of zoom in/out animations in ms */
|
|
388
|
+
zoom_duration: {
|
|
389
|
+
type: jspsych.ParameterType.INT,
|
|
390
|
+
default: 300
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
data: {
|
|
394
|
+
/** Validation success status */
|
|
395
|
+
validation_success: {
|
|
396
|
+
type: jspsych.ParameterType.BOOL
|
|
397
|
+
},
|
|
398
|
+
/** Average accuracy */
|
|
399
|
+
average_accuracy: {
|
|
400
|
+
type: jspsych.ParameterType.FLOAT
|
|
401
|
+
},
|
|
402
|
+
/** Average precision */
|
|
403
|
+
average_precision: {
|
|
404
|
+
type: jspsych.ParameterType.FLOAT
|
|
405
|
+
},
|
|
406
|
+
/** Number of validation points used */
|
|
407
|
+
num_points: {
|
|
408
|
+
type: jspsych.ParameterType.INT
|
|
409
|
+
},
|
|
410
|
+
/** Full validation result data */
|
|
411
|
+
validation_data: {
|
|
412
|
+
type: jspsych.ParameterType.COMPLEX
|
|
413
|
+
},
|
|
414
|
+
/** Normalized tolerance used for pass/fail determination */
|
|
415
|
+
tolerance: {
|
|
416
|
+
type: jspsych.ParameterType.FLOAT
|
|
417
|
+
},
|
|
418
|
+
/** Number of validation attempts made */
|
|
419
|
+
num_attempts: {
|
|
420
|
+
type: jspsych.ParameterType.INT
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
class TobiiValidationPlugin {
|
|
425
|
+
constructor(jsPsych) {
|
|
426
|
+
this.jsPsych = jsPsych;
|
|
427
|
+
}
|
|
428
|
+
static {
|
|
429
|
+
this.info = info;
|
|
430
|
+
}
|
|
431
|
+
static removeStyles() {
|
|
432
|
+
const el = document.getElementById("tobii-validation-styles");
|
|
433
|
+
if (el) {
|
|
434
|
+
el.remove();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
injectStyles(trial) {
|
|
438
|
+
TobiiValidationPlugin.removeStyles();
|
|
439
|
+
const css = `
|
|
440
|
+
.tobii-validation-container {
|
|
441
|
+
position: fixed;
|
|
442
|
+
top: 0;
|
|
443
|
+
left: 0;
|
|
444
|
+
width: 100%;
|
|
445
|
+
height: 100%;
|
|
446
|
+
background-color: ${trial.background_color};
|
|
447
|
+
font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
448
|
+
z-index: 9999;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.tobii-validation-instructions {
|
|
452
|
+
position: absolute;
|
|
453
|
+
top: 50%;
|
|
454
|
+
left: 50%;
|
|
455
|
+
transform: translate(-50%, -50%);
|
|
456
|
+
background-color: white;
|
|
457
|
+
padding: 40px;
|
|
458
|
+
border-radius: 10px;
|
|
459
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
460
|
+
text-align: center;
|
|
461
|
+
max-width: 600px;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.tobii-validation-instructions h2 {
|
|
465
|
+
margin-top: 0;
|
|
466
|
+
margin-bottom: 20px;
|
|
467
|
+
font-size: 24px;
|
|
468
|
+
color: #333;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.tobii-validation-instructions p {
|
|
472
|
+
margin-bottom: 20px;
|
|
473
|
+
font-size: 16px;
|
|
474
|
+
line-height: 1.5;
|
|
475
|
+
color: #666;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.validation-start-btn,
|
|
479
|
+
.validation-continue-btn,
|
|
480
|
+
.validation-retry-btn {
|
|
481
|
+
background-color: ${trial.button_color};
|
|
482
|
+
color: white;
|
|
483
|
+
border: none;
|
|
484
|
+
padding: 12px 30px;
|
|
485
|
+
font-size: 16px;
|
|
486
|
+
border-radius: 5px;
|
|
487
|
+
cursor: pointer;
|
|
488
|
+
transition: background-color 0.3s;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.validation-start-btn:hover,
|
|
492
|
+
.validation-continue-btn:hover {
|
|
493
|
+
background-color: ${trial.button_hover_color};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.validation-retry-btn {
|
|
497
|
+
background-color: ${trial.retry_button_color};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.validation-retry-btn:hover {
|
|
501
|
+
background-color: ${trial.retry_button_hover_color};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
.tobii-validation-point {
|
|
505
|
+
position: absolute;
|
|
506
|
+
border-radius: 50%;
|
|
507
|
+
transform: translate(-50%, -50%);
|
|
508
|
+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.tobii-validation-point.animation-zoom-out {
|
|
512
|
+
animation: tobii-validation-zoom-out ${trial.zoom_duration / 1e3}s ease-out forwards;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
@keyframes tobii-validation-zoom-out {
|
|
516
|
+
0% {
|
|
517
|
+
transform: translate(-50%, -50%) scale(1);
|
|
518
|
+
}
|
|
519
|
+
100% {
|
|
520
|
+
transform: translate(-50%, -50%) scale(2.5);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
.tobii-validation-point.animation-zoom-in {
|
|
525
|
+
animation: tobii-validation-zoom-in ${trial.zoom_duration / 1e3}s ease-out forwards;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
@keyframes tobii-validation-zoom-in {
|
|
529
|
+
0% {
|
|
530
|
+
transform: translate(-50%, -50%) scale(2.5);
|
|
531
|
+
}
|
|
532
|
+
100% {
|
|
533
|
+
transform: translate(-50%, -50%) scale(1);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.tobii-validation-progress {
|
|
538
|
+
position: fixed;
|
|
539
|
+
top: 20px;
|
|
540
|
+
left: 50%;
|
|
541
|
+
transform: translateX(-50%);
|
|
542
|
+
background-color: rgba(0, 0, 0, 0.7);
|
|
543
|
+
color: white;
|
|
544
|
+
padding: 10px 20px;
|
|
545
|
+
border-radius: 5px;
|
|
546
|
+
font-size: 14px;
|
|
547
|
+
z-index: 10000;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.tobii-validation-result {
|
|
551
|
+
position: absolute;
|
|
552
|
+
top: 50%;
|
|
553
|
+
left: 50%;
|
|
554
|
+
transform: translate(-50%, -50%);
|
|
555
|
+
background-color: white;
|
|
556
|
+
padding: 30px 40px;
|
|
557
|
+
border-radius: 10px;
|
|
558
|
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
559
|
+
text-align: center;
|
|
560
|
+
width: 60vw;
|
|
561
|
+
max-width: 800px;
|
|
562
|
+
max-height: 85vh;
|
|
563
|
+
overflow-y: auto;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.tobii-validation-result-content h2 {
|
|
567
|
+
margin-top: 0;
|
|
568
|
+
margin-bottom: 20px;
|
|
569
|
+
font-size: 24px;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.tobii-validation-result-content.success h2 {
|
|
573
|
+
color: ${trial.success_color};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
.tobii-validation-result-content.error h2 {
|
|
577
|
+
color: ${trial.error_color};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
.tobii-validation-result-content p {
|
|
581
|
+
margin-bottom: 15px;
|
|
582
|
+
font-size: 16px;
|
|
583
|
+
color: #666;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
.validation-feedback {
|
|
587
|
+
margin: 30px 0;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.validation-feedback h3 {
|
|
591
|
+
margin-bottom: 15px;
|
|
592
|
+
font-size: 18px;
|
|
593
|
+
color: #333;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
.feedback-canvas {
|
|
597
|
+
position: relative;
|
|
598
|
+
width: 100%;
|
|
599
|
+
height: 300px;
|
|
600
|
+
background-color: #f0f0f0;
|
|
601
|
+
border: 2px solid #ddd;
|
|
602
|
+
border-radius: 5px;
|
|
603
|
+
margin-bottom: 15px;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
.feedback-point {
|
|
607
|
+
position: absolute;
|
|
608
|
+
width: 20px;
|
|
609
|
+
height: 20px;
|
|
610
|
+
border-radius: 50%;
|
|
611
|
+
transform: translate(-50%, -50%);
|
|
612
|
+
border: 2px solid #333;
|
|
613
|
+
cursor: help;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.feedback-legend {
|
|
617
|
+
display: flex;
|
|
618
|
+
justify-content: center;
|
|
619
|
+
gap: 20px;
|
|
620
|
+
font-size: 14px;
|
|
621
|
+
color: #666;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.feedback-legend span {
|
|
625
|
+
display: flex;
|
|
626
|
+
align-items: center;
|
|
627
|
+
gap: 5px;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.legend-color {
|
|
631
|
+
display: inline-block;
|
|
632
|
+
width: 15px;
|
|
633
|
+
height: 15px;
|
|
634
|
+
border-radius: 50%;
|
|
635
|
+
border: 1px solid #333;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.target-legend {
|
|
639
|
+
background-color: transparent;
|
|
640
|
+
border: 3px solid #333;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.feedback-canvas-fullscreen {
|
|
644
|
+
position: relative;
|
|
645
|
+
width: 100%;
|
|
646
|
+
background-color: #2a2a2a;
|
|
647
|
+
border: 2px solid #444;
|
|
648
|
+
border-radius: 5px;
|
|
649
|
+
margin-bottom: 15px;
|
|
650
|
+
overflow: hidden;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
.feedback-target {
|
|
654
|
+
position: absolute;
|
|
655
|
+
width: 24px;
|
|
656
|
+
height: 24px;
|
|
657
|
+
border-radius: 50%;
|
|
658
|
+
transform: translate(-50%, -50%);
|
|
659
|
+
border: 3px solid #fff;
|
|
660
|
+
background-color: transparent;
|
|
661
|
+
z-index: 10;
|
|
662
|
+
display: flex;
|
|
663
|
+
align-items: center;
|
|
664
|
+
justify-content: center;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.target-label {
|
|
668
|
+
color: #fff;
|
|
669
|
+
font-size: 10px;
|
|
670
|
+
font-weight: bold;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.feedback-gaze {
|
|
674
|
+
position: absolute;
|
|
675
|
+
width: 20px;
|
|
676
|
+
height: 20px;
|
|
677
|
+
border-radius: 50%;
|
|
678
|
+
transform: translate(-50%, -50%);
|
|
679
|
+
border: 2px solid;
|
|
680
|
+
z-index: 11;
|
|
681
|
+
display: flex;
|
|
682
|
+
align-items: center;
|
|
683
|
+
justify-content: center;
|
|
684
|
+
cursor: help;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.gaze-label {
|
|
688
|
+
color: #000;
|
|
689
|
+
font-size: 9px;
|
|
690
|
+
font-weight: bold;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
.feedback-sample {
|
|
694
|
+
position: absolute;
|
|
695
|
+
width: 4px;
|
|
696
|
+
height: 4px;
|
|
697
|
+
border-radius: 50%;
|
|
698
|
+
transform: translate(-50%, -50%);
|
|
699
|
+
background-color: rgba(100, 100, 255, 0.4);
|
|
700
|
+
z-index: 5;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.accuracy-table {
|
|
704
|
+
width: 100%;
|
|
705
|
+
border-collapse: collapse;
|
|
706
|
+
margin-top: 15px;
|
|
707
|
+
font-size: 13px;
|
|
708
|
+
text-align: left;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.accuracy-table th,
|
|
712
|
+
.accuracy-table td {
|
|
713
|
+
padding: 8px 12px;
|
|
714
|
+
border: 1px solid #ddd;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
.accuracy-table th {
|
|
718
|
+
background-color: #f5f5f5;
|
|
719
|
+
font-weight: 600;
|
|
720
|
+
color: #333;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.accuracy-table tr:nth-child(even) {
|
|
724
|
+
background-color: #fafafa;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
.accuracy-table td {
|
|
728
|
+
color: #555;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
.saccade-note {
|
|
732
|
+
font-size: 12px;
|
|
733
|
+
color: #888;
|
|
734
|
+
font-style: italic;
|
|
735
|
+
margin-top: 10px;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
.tolerance-info {
|
|
739
|
+
font-size: 14px;
|
|
740
|
+
color: #666;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.gaze-pass-legend {
|
|
744
|
+
background-color: #4ade80;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.gaze-fail-legend {
|
|
748
|
+
background-color: #f87171;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.feedback-gaze.gaze-pass {
|
|
752
|
+
background-color: #4ade80;
|
|
753
|
+
border-color: #22c55e;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
.feedback-gaze.gaze-fail {
|
|
757
|
+
background-color: #f87171;
|
|
758
|
+
border-color: #ef4444;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.feedback-sample.sample-pass {
|
|
762
|
+
background-color: rgba(74, 222, 128, 0.5);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.feedback-sample.sample-fail {
|
|
766
|
+
background-color: rgba(248, 113, 113, 0.5);
|
|
767
|
+
}
|
|
768
|
+
`;
|
|
769
|
+
const styleElement = document.createElement("style");
|
|
770
|
+
styleElement.id = "tobii-validation-styles";
|
|
771
|
+
styleElement.textContent = css;
|
|
772
|
+
document.head.appendChild(styleElement);
|
|
773
|
+
}
|
|
774
|
+
async trial(display_element, trial) {
|
|
775
|
+
this.injectStyles(trial);
|
|
776
|
+
const tobiiExt = this.jsPsych.extensions.tobii;
|
|
777
|
+
if (!tobiiExt) {
|
|
778
|
+
throw new Error("Tobii extension not initialized");
|
|
779
|
+
}
|
|
780
|
+
if (!tobiiExt.isConnected()) {
|
|
781
|
+
throw new Error("Not connected to Tobii server");
|
|
782
|
+
}
|
|
783
|
+
const validationDisplay = new ValidationDisplay(
|
|
784
|
+
display_element,
|
|
785
|
+
trial
|
|
786
|
+
);
|
|
787
|
+
await validationDisplay.showInstructions();
|
|
788
|
+
let points;
|
|
789
|
+
if (trial.custom_points) {
|
|
790
|
+
points = this.validateCustomPoints(trial.custom_points);
|
|
791
|
+
} else {
|
|
792
|
+
points = this.getValidationPoints(trial.validation_points);
|
|
793
|
+
}
|
|
794
|
+
const maxAttempts = 1 + trial.max_retries;
|
|
795
|
+
let attempt = 0;
|
|
796
|
+
let validationPassed = false;
|
|
797
|
+
let avgAccuracyNorm = 0;
|
|
798
|
+
let avgPrecisionNorm = 0;
|
|
799
|
+
let validationResult = { success: false };
|
|
800
|
+
try {
|
|
801
|
+
while (attempt < maxAttempts) {
|
|
802
|
+
attempt++;
|
|
803
|
+
const retriesRemaining = maxAttempts - attempt;
|
|
804
|
+
await tobiiExt.startValidation();
|
|
805
|
+
await tobiiExt.startTracking();
|
|
806
|
+
await validationDisplay.initializePoint();
|
|
807
|
+
for (let i = 0; i < points.length; i++) {
|
|
808
|
+
const point = points[i];
|
|
809
|
+
await validationDisplay.travelToPoint(point, i, points.length);
|
|
810
|
+
await validationDisplay.playZoomOut();
|
|
811
|
+
await validationDisplay.playZoomIn();
|
|
812
|
+
const collectionStartTime = performance.now();
|
|
813
|
+
await this.delay(trial.collection_duration);
|
|
814
|
+
const collectionEndTime = performance.now();
|
|
815
|
+
const gazeSamples = await tobiiExt.getGazeData(collectionStartTime, collectionEndTime);
|
|
816
|
+
await tobiiExt.collectValidationPoint(point.x, point.y, gazeSamples);
|
|
817
|
+
if (i < points.length - 1) {
|
|
818
|
+
await validationDisplay.resetPointForTravel();
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
await validationDisplay.hidePoint();
|
|
822
|
+
await tobiiExt.stopTracking();
|
|
823
|
+
validationResult = await tobiiExt.computeValidation();
|
|
824
|
+
avgAccuracyNorm = validationResult.averageAccuracyNorm || 0;
|
|
825
|
+
avgPrecisionNorm = validationResult.averagePrecisionNorm || 0;
|
|
826
|
+
validationPassed = validationResult.success && avgAccuracyNorm <= trial.tolerance;
|
|
827
|
+
const userChoice = await validationDisplay.showResult(
|
|
828
|
+
validationPassed,
|
|
829
|
+
avgAccuracyNorm,
|
|
830
|
+
avgPrecisionNorm,
|
|
831
|
+
validationResult.pointData || [],
|
|
832
|
+
trial.tolerance,
|
|
833
|
+
retriesRemaining > 0
|
|
834
|
+
);
|
|
835
|
+
if (userChoice === "continue") {
|
|
836
|
+
break;
|
|
837
|
+
}
|
|
838
|
+
validationDisplay.resetForRetry();
|
|
839
|
+
}
|
|
840
|
+
} finally {
|
|
841
|
+
validationDisplay.clear();
|
|
842
|
+
display_element.innerHTML = "";
|
|
843
|
+
TobiiValidationPlugin.removeStyles();
|
|
844
|
+
}
|
|
845
|
+
const trial_data = {
|
|
846
|
+
validation_success: validationPassed,
|
|
847
|
+
average_accuracy: avgAccuracyNorm,
|
|
848
|
+
average_precision: avgPrecisionNorm,
|
|
849
|
+
tolerance: trial.tolerance,
|
|
850
|
+
num_points: points.length,
|
|
851
|
+
validation_data: validationResult,
|
|
852
|
+
num_attempts: attempt
|
|
853
|
+
};
|
|
854
|
+
this.jsPsych.finishTrial(trial_data);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Validate custom validation points
|
|
858
|
+
*/
|
|
859
|
+
validateCustomPoints(points) {
|
|
860
|
+
if (!Array.isArray(points) || points.length === 0) {
|
|
861
|
+
throw new Error("custom_points must be a non-empty array");
|
|
862
|
+
}
|
|
863
|
+
const validated = [];
|
|
864
|
+
for (let i = 0; i < points.length; i++) {
|
|
865
|
+
const point = points[i];
|
|
866
|
+
if (typeof point !== "object" || point === null || typeof point.x !== "number" || typeof point.y !== "number") {
|
|
867
|
+
throw new Error(`Invalid validation point at index ${i}: must have numeric x and y`);
|
|
868
|
+
}
|
|
869
|
+
if (point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1) {
|
|
870
|
+
throw new Error(
|
|
871
|
+
`Validation point at index ${i} out of range: x and y must be between 0 and 1`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
validated.push({ x: point.x, y: point.y });
|
|
875
|
+
}
|
|
876
|
+
return validated;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Get standard validation points for the given grid size
|
|
880
|
+
*/
|
|
881
|
+
getValidationPoints(count) {
|
|
882
|
+
switch (count) {
|
|
883
|
+
case 5:
|
|
884
|
+
return [
|
|
885
|
+
{ x: 0.1, y: 0.1 },
|
|
886
|
+
{ x: 0.9, y: 0.1 },
|
|
887
|
+
{ x: 0.5, y: 0.5 },
|
|
888
|
+
{ x: 0.1, y: 0.9 },
|
|
889
|
+
{ x: 0.9, y: 0.9 }
|
|
890
|
+
];
|
|
891
|
+
case 9:
|
|
892
|
+
return [
|
|
893
|
+
{ x: 0.1, y: 0.1 },
|
|
894
|
+
{ x: 0.5, y: 0.1 },
|
|
895
|
+
{ x: 0.9, y: 0.1 },
|
|
896
|
+
{ x: 0.1, y: 0.5 },
|
|
897
|
+
{ x: 0.5, y: 0.5 },
|
|
898
|
+
{ x: 0.9, y: 0.5 },
|
|
899
|
+
{ x: 0.1, y: 0.9 },
|
|
900
|
+
{ x: 0.5, y: 0.9 },
|
|
901
|
+
{ x: 0.9, y: 0.9 }
|
|
902
|
+
];
|
|
903
|
+
case 13:
|
|
904
|
+
return [
|
|
905
|
+
{ x: 0.1, y: 0.1 },
|
|
906
|
+
{ x: 0.5, y: 0.1 },
|
|
907
|
+
{ x: 0.9, y: 0.1 },
|
|
908
|
+
{ x: 0.3, y: 0.3 },
|
|
909
|
+
{ x: 0.7, y: 0.3 },
|
|
910
|
+
{ x: 0.1, y: 0.5 },
|
|
911
|
+
{ x: 0.5, y: 0.5 },
|
|
912
|
+
{ x: 0.9, y: 0.5 },
|
|
913
|
+
{ x: 0.3, y: 0.7 },
|
|
914
|
+
{ x: 0.7, y: 0.7 },
|
|
915
|
+
{ x: 0.1, y: 0.9 },
|
|
916
|
+
{ x: 0.5, y: 0.9 },
|
|
917
|
+
{ x: 0.9, y: 0.9 }
|
|
918
|
+
];
|
|
919
|
+
case 15:
|
|
920
|
+
return [
|
|
921
|
+
{ x: 0.1, y: 0.1 },
|
|
922
|
+
{ x: 0.5, y: 0.1 },
|
|
923
|
+
{ x: 0.9, y: 0.1 },
|
|
924
|
+
{ x: 0.1, y: 0.3 },
|
|
925
|
+
{ x: 0.5, y: 0.3 },
|
|
926
|
+
{ x: 0.9, y: 0.3 },
|
|
927
|
+
{ x: 0.1, y: 0.5 },
|
|
928
|
+
{ x: 0.5, y: 0.5 },
|
|
929
|
+
{ x: 0.9, y: 0.5 },
|
|
930
|
+
{ x: 0.1, y: 0.7 },
|
|
931
|
+
{ x: 0.5, y: 0.7 },
|
|
932
|
+
{ x: 0.9, y: 0.7 },
|
|
933
|
+
{ x: 0.1, y: 0.9 },
|
|
934
|
+
{ x: 0.5, y: 0.9 },
|
|
935
|
+
{ x: 0.9, y: 0.9 }
|
|
936
|
+
];
|
|
937
|
+
case 19:
|
|
938
|
+
return [
|
|
939
|
+
{ x: 0.1, y: 0.1 },
|
|
940
|
+
{ x: 0.5, y: 0.1 },
|
|
941
|
+
{ x: 0.9, y: 0.1 },
|
|
942
|
+
{ x: 0.1, y: 0.3 },
|
|
943
|
+
{ x: 0.3, y: 0.3 },
|
|
944
|
+
{ x: 0.5, y: 0.3 },
|
|
945
|
+
{ x: 0.7, y: 0.3 },
|
|
946
|
+
{ x: 0.9, y: 0.3 },
|
|
947
|
+
{ x: 0.1, y: 0.5 },
|
|
948
|
+
{ x: 0.5, y: 0.5 },
|
|
949
|
+
{ x: 0.9, y: 0.5 },
|
|
950
|
+
{ x: 0.1, y: 0.7 },
|
|
951
|
+
{ x: 0.3, y: 0.7 },
|
|
952
|
+
{ x: 0.5, y: 0.7 },
|
|
953
|
+
{ x: 0.7, y: 0.7 },
|
|
954
|
+
{ x: 0.9, y: 0.7 },
|
|
955
|
+
{ x: 0.1, y: 0.9 },
|
|
956
|
+
{ x: 0.5, y: 0.9 },
|
|
957
|
+
{ x: 0.9, y: 0.9 }
|
|
958
|
+
];
|
|
959
|
+
case 25:
|
|
960
|
+
return [
|
|
961
|
+
{ x: 0.1, y: 0.1 },
|
|
962
|
+
{ x: 0.3, y: 0.1 },
|
|
963
|
+
{ x: 0.5, y: 0.1 },
|
|
964
|
+
{ x: 0.7, y: 0.1 },
|
|
965
|
+
{ x: 0.9, y: 0.1 },
|
|
966
|
+
{ x: 0.1, y: 0.3 },
|
|
967
|
+
{ x: 0.3, y: 0.3 },
|
|
968
|
+
{ x: 0.5, y: 0.3 },
|
|
969
|
+
{ x: 0.7, y: 0.3 },
|
|
970
|
+
{ x: 0.9, y: 0.3 },
|
|
971
|
+
{ x: 0.1, y: 0.5 },
|
|
972
|
+
{ x: 0.3, y: 0.5 },
|
|
973
|
+
{ x: 0.5, y: 0.5 },
|
|
974
|
+
{ x: 0.7, y: 0.5 },
|
|
975
|
+
{ x: 0.9, y: 0.5 },
|
|
976
|
+
{ x: 0.1, y: 0.7 },
|
|
977
|
+
{ x: 0.3, y: 0.7 },
|
|
978
|
+
{ x: 0.5, y: 0.7 },
|
|
979
|
+
{ x: 0.7, y: 0.7 },
|
|
980
|
+
{ x: 0.9, y: 0.7 },
|
|
981
|
+
{ x: 0.1, y: 0.9 },
|
|
982
|
+
{ x: 0.3, y: 0.9 },
|
|
983
|
+
{ x: 0.5, y: 0.9 },
|
|
984
|
+
{ x: 0.7, y: 0.9 },
|
|
985
|
+
{ x: 0.9, y: 0.9 }
|
|
986
|
+
];
|
|
987
|
+
default:
|
|
988
|
+
throw new Error(
|
|
989
|
+
`Unsupported validation_points value: ${count}. Use 5, 9, 13, 15, 19, or 25, or provide custom_points.`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Delay helper
|
|
995
|
+
*/
|
|
996
|
+
delay(ms) {
|
|
997
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
module.exports = TobiiValidationPlugin;
|
|
1002
|
+
//# sourceMappingURL=index.cjs.map
|