@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
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation display component
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ValidationPoint, ValidationParameters, PointValidationData } from './types';
|
|
6
|
+
|
|
7
|
+
export class ValidationDisplay {
|
|
8
|
+
private container: HTMLElement;
|
|
9
|
+
private currentPoint: HTMLElement | null = null;
|
|
10
|
+
private progressElement: HTMLElement | null = null;
|
|
11
|
+
private currentX: number = 0.5; // Start at center
|
|
12
|
+
private currentY: number = 0.5;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private displayElement: HTMLElement,
|
|
16
|
+
private params: ValidationParameters
|
|
17
|
+
) {
|
|
18
|
+
this.container = this.createContainer();
|
|
19
|
+
this.displayElement.appendChild(this.container);
|
|
20
|
+
|
|
21
|
+
if (params.show_progress) {
|
|
22
|
+
this.progressElement = this.createProgressIndicator();
|
|
23
|
+
this.displayElement.appendChild(this.progressElement);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private createContainer(): HTMLElement {
|
|
28
|
+
const container = document.createElement('div');
|
|
29
|
+
container.className = 'tobii-validation-container';
|
|
30
|
+
return container;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private createProgressIndicator(): HTMLElement {
|
|
34
|
+
const progress = document.createElement('div');
|
|
35
|
+
progress.className = 'tobii-validation-progress';
|
|
36
|
+
progress.setAttribute('role', 'status');
|
|
37
|
+
progress.setAttribute('aria-live', 'polite');
|
|
38
|
+
return progress;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async showInstructions(): Promise<void> {
|
|
42
|
+
const wrapper = document.createElement('div');
|
|
43
|
+
wrapper.className = 'tobii-validation-instructions';
|
|
44
|
+
wrapper.setAttribute('role', 'dialog');
|
|
45
|
+
wrapper.setAttribute('aria-label', 'Eye tracker validation instructions');
|
|
46
|
+
|
|
47
|
+
const content = document.createElement('div');
|
|
48
|
+
content.className = 'instructions-content';
|
|
49
|
+
|
|
50
|
+
const heading = document.createElement('h2');
|
|
51
|
+
heading.textContent = 'Eye Tracker Validation';
|
|
52
|
+
content.appendChild(heading);
|
|
53
|
+
|
|
54
|
+
const paragraph = document.createElement('p');
|
|
55
|
+
paragraph.innerHTML =
|
|
56
|
+
this.params.instructions || 'Look at each point to validate calibration accuracy.';
|
|
57
|
+
content.appendChild(paragraph);
|
|
58
|
+
|
|
59
|
+
const button = document.createElement('button');
|
|
60
|
+
button.className = 'validation-start-btn';
|
|
61
|
+
button.textContent = 'Start Validation';
|
|
62
|
+
content.appendChild(button);
|
|
63
|
+
|
|
64
|
+
wrapper.appendChild(content);
|
|
65
|
+
this.container.appendChild(wrapper);
|
|
66
|
+
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
button.addEventListener('click', () => {
|
|
69
|
+
wrapper.remove();
|
|
70
|
+
resolve();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Initialize the traveling point at screen center
|
|
77
|
+
*/
|
|
78
|
+
async initializePoint(): Promise<void> {
|
|
79
|
+
if (this.currentPoint) return;
|
|
80
|
+
|
|
81
|
+
this.currentPoint = document.createElement('div');
|
|
82
|
+
this.currentPoint.className = 'tobii-validation-point';
|
|
83
|
+
this.currentPoint.setAttribute('role', 'img');
|
|
84
|
+
this.currentPoint.setAttribute('aria-label', 'Validation target point');
|
|
85
|
+
|
|
86
|
+
// Start at center
|
|
87
|
+
const x = 0.5 * window.innerWidth;
|
|
88
|
+
const y = 0.5 * window.innerHeight;
|
|
89
|
+
this.currentX = 0.5;
|
|
90
|
+
this.currentY = 0.5;
|
|
91
|
+
|
|
92
|
+
Object.assign(this.currentPoint.style, {
|
|
93
|
+
left: `${x}px`,
|
|
94
|
+
top: `${y}px`,
|
|
95
|
+
width: `${this.params.point_size || 20}px`,
|
|
96
|
+
height: `${this.params.point_size || 20}px`,
|
|
97
|
+
backgroundColor: this.params.point_color || '#00ff00',
|
|
98
|
+
transition: 'none',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
this.container.appendChild(this.currentPoint);
|
|
102
|
+
|
|
103
|
+
// Brief pause to show point at center before traveling
|
|
104
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Travel to the next point location with smooth animation
|
|
109
|
+
*/
|
|
110
|
+
async travelToPoint(point: ValidationPoint, index: number, total: number): Promise<void> {
|
|
111
|
+
if (!this.currentPoint) {
|
|
112
|
+
await this.initializePoint();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Update progress
|
|
116
|
+
if (this.progressElement) {
|
|
117
|
+
this.progressElement.textContent = `Point ${index + 1} of ${total}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Update aria-label with current point number
|
|
121
|
+
this.currentPoint!.setAttribute(
|
|
122
|
+
'aria-label',
|
|
123
|
+
`Validation target point ${index + 1} of ${total}`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Calculate travel distance for dynamic duration
|
|
127
|
+
const dx = point.x - this.currentX;
|
|
128
|
+
const dy = point.y - this.currentY;
|
|
129
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
130
|
+
|
|
131
|
+
// Travel duration: 150ms base + 200ms per normalized unit distance (quick travel)
|
|
132
|
+
const travelDuration = Math.max(150, Math.min(400, 150 + distance * 200));
|
|
133
|
+
|
|
134
|
+
// Convert normalized coordinates to pixels
|
|
135
|
+
const x = point.x * window.innerWidth;
|
|
136
|
+
const y = point.y * window.innerHeight;
|
|
137
|
+
|
|
138
|
+
// Set up travel transition
|
|
139
|
+
this.currentPoint!.style.transition = `left ${travelDuration}ms ease-in-out, top ${travelDuration}ms ease-in-out`;
|
|
140
|
+
this.currentPoint!.classList.remove('animation-zoom-out', 'animation-zoom-in');
|
|
141
|
+
|
|
142
|
+
// Move to new position
|
|
143
|
+
this.currentPoint!.style.left = `${x}px`;
|
|
144
|
+
this.currentPoint!.style.top = `${y}px`;
|
|
145
|
+
|
|
146
|
+
// Update current position
|
|
147
|
+
this.currentX = point.x;
|
|
148
|
+
this.currentY = point.y;
|
|
149
|
+
|
|
150
|
+
// Wait for travel to complete
|
|
151
|
+
await this.delay(travelDuration);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Play zoom out animation (point grows larger)
|
|
156
|
+
*/
|
|
157
|
+
async playZoomOut(): Promise<void> {
|
|
158
|
+
if (!this.currentPoint) return;
|
|
159
|
+
|
|
160
|
+
this.currentPoint.style.transition = 'none';
|
|
161
|
+
this.currentPoint.classList.remove('animation-zoom-in');
|
|
162
|
+
this.currentPoint.classList.add('animation-zoom-out');
|
|
163
|
+
|
|
164
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Play zoom in animation (point shrinks to fixation size)
|
|
169
|
+
*/
|
|
170
|
+
async playZoomIn(): Promise<void> {
|
|
171
|
+
if (!this.currentPoint) return;
|
|
172
|
+
|
|
173
|
+
this.currentPoint.classList.remove('animation-zoom-out');
|
|
174
|
+
this.currentPoint.classList.add('animation-zoom-in');
|
|
175
|
+
|
|
176
|
+
await this.delay(this.params.zoom_duration || 300);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reset point state after data collection (keeps element for continued travel)
|
|
181
|
+
*/
|
|
182
|
+
async resetPointForTravel(): Promise<void> {
|
|
183
|
+
if (!this.currentPoint) return;
|
|
184
|
+
|
|
185
|
+
// Reset to normal size for next travel
|
|
186
|
+
this.currentPoint.classList.remove('animation-zoom-out', 'animation-zoom-in');
|
|
187
|
+
this.currentPoint.style.transform = 'translate(-50%, -50%) scale(1)';
|
|
188
|
+
|
|
189
|
+
await this.delay(50);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async hidePoint(): Promise<void> {
|
|
193
|
+
if (this.currentPoint) {
|
|
194
|
+
this.currentPoint.remove();
|
|
195
|
+
this.currentPoint = null;
|
|
196
|
+
}
|
|
197
|
+
await this.delay(200);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Show validation result
|
|
202
|
+
* @param success Whether validation passed
|
|
203
|
+
* @param averageAccuracyNorm Average accuracy in normalized units
|
|
204
|
+
* @param averagePrecisionNorm Average precision in normalized units
|
|
205
|
+
* @param pointData Per-point validation data
|
|
206
|
+
* @param tolerance Tolerance threshold
|
|
207
|
+
* @param canRetry Whether a retry button should be shown on failure
|
|
208
|
+
* @returns 'retry' if user chose to retry, 'continue' otherwise
|
|
209
|
+
*/
|
|
210
|
+
async showResult(
|
|
211
|
+
success: boolean,
|
|
212
|
+
averageAccuracyNorm?: number,
|
|
213
|
+
_averagePrecisionNorm?: number,
|
|
214
|
+
pointData?: PointValidationData[],
|
|
215
|
+
tolerance?: number,
|
|
216
|
+
canRetry: boolean = false
|
|
217
|
+
): Promise<'retry' | 'continue'> {
|
|
218
|
+
const result = document.createElement('div');
|
|
219
|
+
result.className = 'tobii-validation-result';
|
|
220
|
+
result.setAttribute('role', 'alert');
|
|
221
|
+
result.setAttribute('aria-live', 'assertive');
|
|
222
|
+
|
|
223
|
+
let feedbackHTML = '';
|
|
224
|
+
if (this.params.show_feedback && pointData) {
|
|
225
|
+
feedbackHTML = this.createVisualFeedback(pointData, tolerance);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const statusClass = success ? 'success' : 'error';
|
|
229
|
+
const statusText = success ? 'Validation Passed' : 'Validation Failed';
|
|
230
|
+
|
|
231
|
+
let buttonsHTML: string;
|
|
232
|
+
if (success) {
|
|
233
|
+
buttonsHTML = `<button class="validation-continue-btn">Continue</button>`;
|
|
234
|
+
} else if (canRetry) {
|
|
235
|
+
buttonsHTML = `<button class="validation-retry-btn">Retry</button>
|
|
236
|
+
<button class="validation-continue-btn" style="margin-left: 10px;">Continue</button>`;
|
|
237
|
+
} else {
|
|
238
|
+
buttonsHTML = `<button class="validation-continue-btn">Continue</button>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
result.innerHTML = `
|
|
242
|
+
<div class="tobii-validation-result-content ${statusClass}">
|
|
243
|
+
<h2>${statusText}</h2>
|
|
244
|
+
<p>Average error: ${((averageAccuracyNorm || 0) * 100).toFixed(1)}% (tolerance: ${((tolerance || 0) * 100).toFixed(0)}%)</p>
|
|
245
|
+
${feedbackHTML}
|
|
246
|
+
${buttonsHTML}
|
|
247
|
+
</div>
|
|
248
|
+
`;
|
|
249
|
+
|
|
250
|
+
this.container.appendChild(result);
|
|
251
|
+
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
const retryBtn = result.querySelector('.validation-retry-btn');
|
|
254
|
+
const continueBtn = result.querySelector('.validation-continue-btn');
|
|
255
|
+
|
|
256
|
+
retryBtn?.addEventListener('click', () => {
|
|
257
|
+
result.remove();
|
|
258
|
+
resolve('retry');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
continueBtn?.addEventListener('click', () => {
|
|
262
|
+
result.remove();
|
|
263
|
+
resolve('continue');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Reset display state for a retry attempt
|
|
270
|
+
*/
|
|
271
|
+
resetForRetry(): void {
|
|
272
|
+
this.container.innerHTML = '';
|
|
273
|
+
this.currentPoint = null;
|
|
274
|
+
this.currentX = 0.5;
|
|
275
|
+
this.currentY = 0.5;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private createVisualFeedback(pointData: PointValidationData[], tolerance?: number): string {
|
|
279
|
+
const tol = tolerance || 0.05;
|
|
280
|
+
|
|
281
|
+
// Create full-screen visualization showing actual screen positions
|
|
282
|
+
const targetMarkers = pointData
|
|
283
|
+
.map((data, idx) => {
|
|
284
|
+
const x = data.point.x * 100;
|
|
285
|
+
const y = data.point.y * 100;
|
|
286
|
+
return `
|
|
287
|
+
<div class="feedback-target" style="
|
|
288
|
+
left: ${x}%;
|
|
289
|
+
top: ${y}%;
|
|
290
|
+
" title="Target ${idx + 1}">
|
|
291
|
+
<span class="target-label">${idx + 1}</span>
|
|
292
|
+
</div>
|
|
293
|
+
`;
|
|
294
|
+
})
|
|
295
|
+
.join('');
|
|
296
|
+
|
|
297
|
+
// Mean gaze positions - color coded based on tolerance with non-color indicators
|
|
298
|
+
const gazeMarkers = pointData
|
|
299
|
+
.map((data, idx) => {
|
|
300
|
+
if (!data.meanGaze) return '';
|
|
301
|
+
const x = data.meanGaze.x * 100;
|
|
302
|
+
const y = data.meanGaze.y * 100;
|
|
303
|
+
const withinTolerance = data.accuracyNorm <= tol;
|
|
304
|
+
const colorClass = withinTolerance ? 'gaze-pass' : 'gaze-fail';
|
|
305
|
+
const statusSymbol = withinTolerance ? '\u2713' : '\u2717';
|
|
306
|
+
const statusLabel = withinTolerance ? 'pass' : 'fail';
|
|
307
|
+
return `
|
|
308
|
+
<div class="feedback-gaze ${colorClass}" style="
|
|
309
|
+
left: ${x}%;
|
|
310
|
+
top: ${y}%;
|
|
311
|
+
" title="Point ${idx + 1}: ${statusLabel}, error ${(data.accuracyNorm * 100).toFixed(1)}%"
|
|
312
|
+
aria-label="Point ${idx + 1}: ${statusLabel}, error ${(data.accuracyNorm * 100).toFixed(1)}%">
|
|
313
|
+
<span class="gaze-label">${statusSymbol}</span>
|
|
314
|
+
</div>
|
|
315
|
+
`;
|
|
316
|
+
})
|
|
317
|
+
.join('');
|
|
318
|
+
|
|
319
|
+
// Draw lines connecting targets to mean gaze positions - color coded
|
|
320
|
+
const connectionLines = pointData
|
|
321
|
+
.map((data) => {
|
|
322
|
+
if (!data.meanGaze) return '';
|
|
323
|
+
const x1 = data.point.x * 100;
|
|
324
|
+
const y1 = data.point.y * 100;
|
|
325
|
+
const x2 = data.meanGaze.x * 100;
|
|
326
|
+
const y2 = data.meanGaze.y * 100;
|
|
327
|
+
const withinTolerance = data.accuracyNorm <= tol;
|
|
328
|
+
const lineColor = withinTolerance ? '#4ade80' : '#f87171';
|
|
329
|
+
return `
|
|
330
|
+
<svg class="feedback-line" style="position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none;">
|
|
331
|
+
<line x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%"
|
|
332
|
+
stroke="${lineColor}" stroke-width="2" stroke-dasharray="5,3" opacity="0.7"/>
|
|
333
|
+
</svg>
|
|
334
|
+
`;
|
|
335
|
+
})
|
|
336
|
+
.join('');
|
|
337
|
+
|
|
338
|
+
// Show all individual gaze samples as small dots - color coded per point
|
|
339
|
+
const gazeSampleDots = pointData
|
|
340
|
+
.map((data) => {
|
|
341
|
+
if (!data.gazeSamples) return '';
|
|
342
|
+
const withinTolerance = data.accuracyNorm <= tol;
|
|
343
|
+
const sampleClass = withinTolerance ? 'sample-pass' : 'sample-fail';
|
|
344
|
+
return data.gazeSamples
|
|
345
|
+
.map((sample) => {
|
|
346
|
+
const x = sample.x * 100;
|
|
347
|
+
const y = sample.y * 100;
|
|
348
|
+
return `<div class="feedback-sample ${sampleClass}" style="left: ${x}%; top: ${y}%;"></div>`;
|
|
349
|
+
})
|
|
350
|
+
.join('');
|
|
351
|
+
})
|
|
352
|
+
.join('');
|
|
353
|
+
|
|
354
|
+
// Calculate aspect ratio from viewport
|
|
355
|
+
const aspectRatio = window.innerWidth / window.innerHeight;
|
|
356
|
+
|
|
357
|
+
const canvas = `
|
|
358
|
+
<div class="validation-feedback">
|
|
359
|
+
<div class="feedback-canvas-fullscreen" style="aspect-ratio: ${aspectRatio.toFixed(3)};">
|
|
360
|
+
${connectionLines}
|
|
361
|
+
${gazeSampleDots}
|
|
362
|
+
${targetMarkers}
|
|
363
|
+
${gazeMarkers}
|
|
364
|
+
</div>
|
|
365
|
+
<div class="feedback-legend">
|
|
366
|
+
<span><span class="legend-color target-legend"></span> Target</span>
|
|
367
|
+
<span><span class="legend-color gaze-pass-legend"></span> Pass (\u2713)</span>
|
|
368
|
+
<span><span class="legend-color gaze-fail-legend"></span> Fail (\u2717)</span>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
`;
|
|
372
|
+
return canvas;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
clear(): void {
|
|
376
|
+
this.container.innerHTML = '';
|
|
377
|
+
if (this.progressElement) {
|
|
378
|
+
this.progressElement.textContent = '';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private delay(ms: number): Promise<void> {
|
|
383
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
384
|
+
}
|
|
385
|
+
}
|