@jspsych/plugin-tobii-user-position 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -0
- package/dist/index.browser.js +690 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.min.js +141 -0
- package/dist/index.browser.min.js.map +1 -0
- package/dist/index.cjs +689 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +282 -0
- package/dist/index.js +687 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/index.d.ts +279 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +337 -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 +102 -0
- package/src/index.spec.js.map +1 -0
- package/src/index.spec.ts +126 -0
- package/src/index.ts +368 -0
- package/src/position-display.d.ts +59 -0
- package/src/position-display.d.ts.map +1 -0
- package/src/position-display.js +421 -0
- package/src/position-display.js.map +1 -0
- package/src/position-display.ts +532 -0
- package/src/types.d.ts +18 -0
- package/src/types.d.ts.map +1 -0
- package/src/types.js +2 -0
- package/src/types.js.map +1 -0
- package/src/types.ts +18 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { UserPositionData, PositionQuality } from './types';
|
|
2
|
+
|
|
3
|
+
export interface PositionDisplayOptions {
|
|
4
|
+
message: string;
|
|
5
|
+
showDistanceFeedback: boolean;
|
|
6
|
+
showPositionFeedback: boolean;
|
|
7
|
+
backgroundColor: string;
|
|
8
|
+
goodColor: string;
|
|
9
|
+
fairColor: string;
|
|
10
|
+
poorColor: string;
|
|
11
|
+
fontSize: string;
|
|
12
|
+
positionThresholdGood: number;
|
|
13
|
+
positionThresholdFair: number;
|
|
14
|
+
distanceThresholdGood: number;
|
|
15
|
+
distanceThresholdFair: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Display component for user position guide
|
|
20
|
+
* Shows a face outline that scales with distance, similar to Tobii Eye Tracker Manager
|
|
21
|
+
*/
|
|
22
|
+
export class PositionDisplay {
|
|
23
|
+
private container: HTMLElement;
|
|
24
|
+
private messageElement!: HTMLElement;
|
|
25
|
+
private trackingBoxElement!: HTMLElement;
|
|
26
|
+
private faceOutlineElement!: HTMLElement;
|
|
27
|
+
private leftEyeElement!: HTMLElement;
|
|
28
|
+
private rightEyeElement!: HTMLElement;
|
|
29
|
+
private distanceBarContainer!: HTMLElement;
|
|
30
|
+
private distanceBarFill!: HTMLElement;
|
|
31
|
+
private feedbackElement!: HTMLElement;
|
|
32
|
+
private options: PositionDisplayOptions;
|
|
33
|
+
|
|
34
|
+
// Constants for the display
|
|
35
|
+
private readonly BOX_WIDTH = 400;
|
|
36
|
+
private readonly BOX_HEIGHT = 300;
|
|
37
|
+
private readonly MIN_FACE_SCALE = 0.4; // Scale when far away
|
|
38
|
+
private readonly MAX_FACE_SCALE = 1.6; // Scale when too close
|
|
39
|
+
private readonly OPTIMAL_FACE_SCALE = 1.0; // Scale at optimal distance
|
|
40
|
+
|
|
41
|
+
constructor(container: HTMLElement, options: PositionDisplayOptions) {
|
|
42
|
+
this.container = container;
|
|
43
|
+
this.options = options;
|
|
44
|
+
this.createDisplay();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private createDisplay(): void {
|
|
48
|
+
this.container.innerHTML = '';
|
|
49
|
+
|
|
50
|
+
// Message
|
|
51
|
+
this.messageElement = document.createElement('div');
|
|
52
|
+
this.messageElement.className = 'tobii-user-position-message';
|
|
53
|
+
this.messageElement.textContent = this.options.message;
|
|
54
|
+
this.container.appendChild(this.messageElement);
|
|
55
|
+
|
|
56
|
+
// Position guide container
|
|
57
|
+
const guideContainer = document.createElement('div');
|
|
58
|
+
guideContainer.className = 'tobii-user-position-guide';
|
|
59
|
+
this.container.appendChild(guideContainer);
|
|
60
|
+
|
|
61
|
+
// Tracking box (represents the optimal tracking zone)
|
|
62
|
+
this.trackingBoxElement = document.createElement('div');
|
|
63
|
+
this.trackingBoxElement.className = 'tobii-tracking-box';
|
|
64
|
+
this.trackingBoxElement.setAttribute('role', 'img');
|
|
65
|
+
this.trackingBoxElement.setAttribute('aria-label', 'Head position tracking display');
|
|
66
|
+
this.trackingBoxElement.style.cssText = `
|
|
67
|
+
position: relative;
|
|
68
|
+
width: ${this.BOX_WIDTH}px;
|
|
69
|
+
height: ${this.BOX_HEIGHT}px;
|
|
70
|
+
border: 3px solid #666;
|
|
71
|
+
border-radius: 12px;
|
|
72
|
+
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
box-shadow: inset 0 0 30px rgba(0,0,0,0.5);
|
|
75
|
+
`;
|
|
76
|
+
guideContainer.appendChild(this.trackingBoxElement);
|
|
77
|
+
|
|
78
|
+
// Optimal zone indicator (center rectangle)
|
|
79
|
+
const optimalZone = document.createElement('div');
|
|
80
|
+
optimalZone.style.cssText = `
|
|
81
|
+
position: absolute;
|
|
82
|
+
top: 50%;
|
|
83
|
+
left: 50%;
|
|
84
|
+
transform: translate(-50%, -50%);
|
|
85
|
+
width: 60%;
|
|
86
|
+
height: 70%;
|
|
87
|
+
border: 2px dashed rgba(255,255,255,0.2);
|
|
88
|
+
border-radius: 8px;
|
|
89
|
+
`;
|
|
90
|
+
this.trackingBoxElement.appendChild(optimalZone);
|
|
91
|
+
|
|
92
|
+
// Face outline container (this moves and scales)
|
|
93
|
+
this.faceOutlineElement = document.createElement('div');
|
|
94
|
+
this.faceOutlineElement.className = 'tobii-face-outline';
|
|
95
|
+
this.faceOutlineElement.setAttribute('aria-hidden', 'true');
|
|
96
|
+
this.faceOutlineElement.style.cssText = `
|
|
97
|
+
position: absolute;
|
|
98
|
+
top: 50%;
|
|
99
|
+
left: 50%;
|
|
100
|
+
transform: translate(-50%, -50%);
|
|
101
|
+
transition: all 0.1s ease-out;
|
|
102
|
+
`;
|
|
103
|
+
this.trackingBoxElement.appendChild(this.faceOutlineElement);
|
|
104
|
+
|
|
105
|
+
// Create face SVG
|
|
106
|
+
this.createFaceSVG();
|
|
107
|
+
|
|
108
|
+
// Distance bar (vertical bar on the side)
|
|
109
|
+
if (this.options.showDistanceFeedback) {
|
|
110
|
+
this.distanceBarContainer = document.createElement('div');
|
|
111
|
+
this.distanceBarContainer.style.cssText = `
|
|
112
|
+
position: absolute;
|
|
113
|
+
right: -40px;
|
|
114
|
+
top: 10%;
|
|
115
|
+
width: 20px;
|
|
116
|
+
height: 80%;
|
|
117
|
+
background: rgba(0,0,0,0.3);
|
|
118
|
+
border-radius: 10px;
|
|
119
|
+
border: 2px solid #444;
|
|
120
|
+
overflow: hidden;
|
|
121
|
+
`;
|
|
122
|
+
guideContainer.appendChild(this.distanceBarContainer);
|
|
123
|
+
|
|
124
|
+
// Distance labels
|
|
125
|
+
const closeLabel = document.createElement('div');
|
|
126
|
+
closeLabel.textContent = 'Close';
|
|
127
|
+
closeLabel.style.cssText = `
|
|
128
|
+
position: absolute;
|
|
129
|
+
right: -70px;
|
|
130
|
+
top: 0;
|
|
131
|
+
font-size: 10px;
|
|
132
|
+
color: #888;
|
|
133
|
+
`;
|
|
134
|
+
guideContainer.appendChild(closeLabel);
|
|
135
|
+
|
|
136
|
+
const farLabel = document.createElement('div');
|
|
137
|
+
farLabel.textContent = 'Far';
|
|
138
|
+
farLabel.style.cssText = `
|
|
139
|
+
position: absolute;
|
|
140
|
+
right: -55px;
|
|
141
|
+
bottom: 10%;
|
|
142
|
+
font-size: 10px;
|
|
143
|
+
color: #888;
|
|
144
|
+
`;
|
|
145
|
+
guideContainer.appendChild(farLabel);
|
|
146
|
+
|
|
147
|
+
this.distanceBarFill = document.createElement('div');
|
|
148
|
+
this.distanceBarFill.style.cssText = `
|
|
149
|
+
position: absolute;
|
|
150
|
+
bottom: 0;
|
|
151
|
+
left: 0;
|
|
152
|
+
width: 100%;
|
|
153
|
+
height: 50%;
|
|
154
|
+
background: ${this.options.goodColor};
|
|
155
|
+
border-radius: 8px;
|
|
156
|
+
transition: all 0.1s ease-out;
|
|
157
|
+
`;
|
|
158
|
+
this.distanceBarContainer.appendChild(this.distanceBarFill);
|
|
159
|
+
|
|
160
|
+
// Optimal zone marker on distance bar
|
|
161
|
+
const optimalMarker = document.createElement('div');
|
|
162
|
+
optimalMarker.style.cssText = `
|
|
163
|
+
position: absolute;
|
|
164
|
+
left: -2px;
|
|
165
|
+
right: -2px;
|
|
166
|
+
top: 40%;
|
|
167
|
+
height: 20%;
|
|
168
|
+
border: 2px solid rgba(255,255,255,0.5);
|
|
169
|
+
border-radius: 4px;
|
|
170
|
+
pointer-events: none;
|
|
171
|
+
`;
|
|
172
|
+
this.distanceBarContainer.appendChild(optimalMarker);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Textual feedback
|
|
176
|
+
this.feedbackElement = document.createElement('div');
|
|
177
|
+
this.feedbackElement.className = 'tobii-position-feedback';
|
|
178
|
+
this.feedbackElement.setAttribute('role', 'status');
|
|
179
|
+
this.feedbackElement.setAttribute('aria-live', 'polite');
|
|
180
|
+
this.feedbackElement.style.cssText = `
|
|
181
|
+
margin-top: 20px;
|
|
182
|
+
font-size: 1.1em;
|
|
183
|
+
font-weight: 600;
|
|
184
|
+
text-align: center;
|
|
185
|
+
`;
|
|
186
|
+
this.container.appendChild(this.feedbackElement);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private createFaceSVG(): void {
|
|
190
|
+
// Base size for the face
|
|
191
|
+
const baseWidth = 120;
|
|
192
|
+
const baseHeight = 150;
|
|
193
|
+
|
|
194
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
195
|
+
svg.setAttribute('width', `${baseWidth}`);
|
|
196
|
+
svg.setAttribute('height', `${baseHeight}`);
|
|
197
|
+
svg.setAttribute('viewBox', `0 0 ${baseWidth} ${baseHeight}`);
|
|
198
|
+
svg.style.cssText = 'overflow: visible;';
|
|
199
|
+
|
|
200
|
+
// Face outline (oval)
|
|
201
|
+
const faceOutline = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse');
|
|
202
|
+
faceOutline.setAttribute('cx', '60');
|
|
203
|
+
faceOutline.setAttribute('cy', '80');
|
|
204
|
+
faceOutline.setAttribute('rx', '50');
|
|
205
|
+
faceOutline.setAttribute('ry', '65');
|
|
206
|
+
faceOutline.setAttribute('fill', 'none');
|
|
207
|
+
faceOutline.setAttribute('stroke', '#888');
|
|
208
|
+
faceOutline.setAttribute('stroke-width', '3');
|
|
209
|
+
faceOutline.setAttribute('stroke-dasharray', '8,4');
|
|
210
|
+
svg.appendChild(faceOutline);
|
|
211
|
+
|
|
212
|
+
// Left eye socket
|
|
213
|
+
this.leftEyeElement = document.createElementNS(
|
|
214
|
+
'http://www.w3.org/2000/svg',
|
|
215
|
+
'circle'
|
|
216
|
+
) as unknown as HTMLElement;
|
|
217
|
+
this.leftEyeElement.setAttribute('cx', '40');
|
|
218
|
+
this.leftEyeElement.setAttribute('cy', '65');
|
|
219
|
+
this.leftEyeElement.setAttribute('r', '12');
|
|
220
|
+
this.leftEyeElement.setAttribute('fill', this.options.poorColor);
|
|
221
|
+
this.leftEyeElement.setAttribute('stroke', '#fff');
|
|
222
|
+
this.leftEyeElement.setAttribute('stroke-width', '2');
|
|
223
|
+
svg.appendChild(this.leftEyeElement as unknown as SVGElement);
|
|
224
|
+
|
|
225
|
+
// Right eye socket
|
|
226
|
+
this.rightEyeElement = document.createElementNS(
|
|
227
|
+
'http://www.w3.org/2000/svg',
|
|
228
|
+
'circle'
|
|
229
|
+
) as unknown as HTMLElement;
|
|
230
|
+
this.rightEyeElement.setAttribute('cx', '80');
|
|
231
|
+
this.rightEyeElement.setAttribute('cy', '65');
|
|
232
|
+
this.rightEyeElement.setAttribute('r', '12');
|
|
233
|
+
this.rightEyeElement.setAttribute('fill', this.options.poorColor);
|
|
234
|
+
this.rightEyeElement.setAttribute('stroke', '#fff');
|
|
235
|
+
this.rightEyeElement.setAttribute('stroke-width', '2');
|
|
236
|
+
svg.appendChild(this.rightEyeElement as unknown as SVGElement);
|
|
237
|
+
|
|
238
|
+
// Nose hint
|
|
239
|
+
const nose = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
240
|
+
nose.setAttribute('d', 'M60,75 L55,95 L65,95 Z');
|
|
241
|
+
nose.setAttribute('fill', 'none');
|
|
242
|
+
nose.setAttribute('stroke', '#666');
|
|
243
|
+
nose.setAttribute('stroke-width', '2');
|
|
244
|
+
svg.appendChild(nose);
|
|
245
|
+
|
|
246
|
+
this.faceOutlineElement.appendChild(svg);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Update the display with new position data
|
|
251
|
+
*/
|
|
252
|
+
public updatePosition(positionData: UserPositionData | null): void {
|
|
253
|
+
if (!positionData) {
|
|
254
|
+
this.showNoData();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Calculate average position from both eyes
|
|
259
|
+
const avgX = this.getAveragePosition(
|
|
260
|
+
positionData.leftX,
|
|
261
|
+
positionData.rightX,
|
|
262
|
+
positionData.leftValid,
|
|
263
|
+
positionData.rightValid
|
|
264
|
+
);
|
|
265
|
+
const avgY = this.getAveragePosition(
|
|
266
|
+
positionData.leftY,
|
|
267
|
+
positionData.rightY,
|
|
268
|
+
positionData.leftValid,
|
|
269
|
+
positionData.rightValid
|
|
270
|
+
);
|
|
271
|
+
const avgZ = this.getAveragePosition(
|
|
272
|
+
positionData.leftZ,
|
|
273
|
+
positionData.rightZ,
|
|
274
|
+
positionData.leftValid,
|
|
275
|
+
positionData.rightValid
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// Update face position and scale
|
|
279
|
+
this.updateFaceDisplay(avgX, avgY, avgZ, positionData);
|
|
280
|
+
|
|
281
|
+
// Update distance bar
|
|
282
|
+
if (this.options.showDistanceFeedback && avgZ !== null) {
|
|
283
|
+
this.updateDistanceBar(avgZ);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Update eye indicators
|
|
287
|
+
this.updateEyeIndicators(positionData);
|
|
288
|
+
|
|
289
|
+
// Update textual feedback
|
|
290
|
+
this.updateTextualFeedback(avgX, avgY, avgZ);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private getAveragePosition(
|
|
294
|
+
left: number | null,
|
|
295
|
+
right: number | null,
|
|
296
|
+
leftValid: boolean,
|
|
297
|
+
rightValid: boolean
|
|
298
|
+
): number | null {
|
|
299
|
+
if (leftValid && rightValid && left !== null && right !== null) {
|
|
300
|
+
return (left + right) / 2;
|
|
301
|
+
} else if (leftValid && left !== null) {
|
|
302
|
+
return left;
|
|
303
|
+
} else if (rightValid && right !== null) {
|
|
304
|
+
return right;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private updateFaceDisplay(
|
|
310
|
+
x: number | null,
|
|
311
|
+
y: number | null,
|
|
312
|
+
z: number | null,
|
|
313
|
+
_positionData: UserPositionData
|
|
314
|
+
): void {
|
|
315
|
+
if (x === null || y === null) {
|
|
316
|
+
this.faceOutlineElement.style.opacity = '0.3';
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
this.faceOutlineElement.style.opacity = '1';
|
|
321
|
+
|
|
322
|
+
// Calculate position offset from center
|
|
323
|
+
// x, y are 0-1 where 0.5 is center
|
|
324
|
+
// Y axis is inverted: y=0 is bottom, y=1 is top, so we invert it for screen coordinates
|
|
325
|
+
const offsetX = (x - 0.5) * this.BOX_WIDTH * 0.8;
|
|
326
|
+
const offsetY = (0.5 - y) * this.BOX_HEIGHT * 0.8; // Inverted Y
|
|
327
|
+
|
|
328
|
+
// Calculate scale based on distance (z)
|
|
329
|
+
// z is 0-1 where ~0.5 is optimal
|
|
330
|
+
// Based on track box: z=0 means at back (far), z=1 means at front (close)
|
|
331
|
+
let scale = this.OPTIMAL_FACE_SCALE;
|
|
332
|
+
if (z !== null) {
|
|
333
|
+
// z=1 (close) -> MAX_FACE_SCALE, z=0.5 -> OPTIMAL_FACE_SCALE, z=0 (far) -> MIN_FACE_SCALE
|
|
334
|
+
scale = this.MIN_FACE_SCALE + z * (this.MAX_FACE_SCALE - this.MIN_FACE_SCALE);
|
|
335
|
+
scale = Math.max(this.MIN_FACE_SCALE, Math.min(this.MAX_FACE_SCALE, scale));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this.faceOutlineElement.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px)) scale(${scale})`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private updateDistanceBar(z: number): void {
|
|
342
|
+
if (!this.distanceBarFill) return;
|
|
343
|
+
|
|
344
|
+
// z is 0-1, where z=1 is close (top of bar) and z=0 is far (bottom)
|
|
345
|
+
// Fill from bottom, so height represents z directly (closeness)
|
|
346
|
+
const fillPercent = z * 100;
|
|
347
|
+
this.distanceBarFill.style.height = `${fillPercent}%`;
|
|
348
|
+
|
|
349
|
+
// Color based on optimal range derived from distance thresholds
|
|
350
|
+
const goodMin = 0.5 - this.options.distanceThresholdGood;
|
|
351
|
+
const goodMax = 0.5 + this.options.distanceThresholdGood;
|
|
352
|
+
const fairMin = 0.5 - this.options.distanceThresholdFair;
|
|
353
|
+
const fairMax = 0.5 + this.options.distanceThresholdFair;
|
|
354
|
+
let color: string;
|
|
355
|
+
if (z >= goodMin && z <= goodMax) {
|
|
356
|
+
color = this.options.goodColor;
|
|
357
|
+
} else if (z >= fairMin && z <= fairMax) {
|
|
358
|
+
color = this.options.fairColor;
|
|
359
|
+
} else {
|
|
360
|
+
color = this.options.poorColor;
|
|
361
|
+
}
|
|
362
|
+
this.distanceBarFill.style.background = color;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private updateEyeIndicators(positionData: UserPositionData): void {
|
|
366
|
+
// Update left eye color based on validity
|
|
367
|
+
if (this.leftEyeElement) {
|
|
368
|
+
const leftColor = positionData.leftValid ? this.options.goodColor : this.options.poorColor;
|
|
369
|
+
this.leftEyeElement.setAttribute('fill', leftColor);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Update right eye color based on validity
|
|
373
|
+
if (this.rightEyeElement) {
|
|
374
|
+
const rightColor = positionData.rightValid ? this.options.goodColor : this.options.poorColor;
|
|
375
|
+
this.rightEyeElement.setAttribute('fill', rightColor);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private updateTextualFeedback(x: number | null, y: number | null, z: number | null): void {
|
|
380
|
+
if (!this.feedbackElement) return;
|
|
381
|
+
|
|
382
|
+
if (x === null || y === null || z === null) {
|
|
383
|
+
this.feedbackElement.textContent =
|
|
384
|
+
'Eyes not detected - please position yourself in front of the tracker';
|
|
385
|
+
this.feedbackElement.style.color = this.options.poorColor;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const quality = this.assessPositionQuality(x, y, z);
|
|
390
|
+
|
|
391
|
+
let feedback: string;
|
|
392
|
+
let color: string;
|
|
393
|
+
|
|
394
|
+
if (quality.isGoodPosition) {
|
|
395
|
+
feedback = '✓ Position is good';
|
|
396
|
+
color = this.options.goodColor;
|
|
397
|
+
} else {
|
|
398
|
+
const issues: string[] = [];
|
|
399
|
+
|
|
400
|
+
// Horizontal feedback — use configurable threshold (offset from 0.5 center)
|
|
401
|
+
const posFairThreshold = this.options.positionThresholdFair;
|
|
402
|
+
if (x < 0.5 - posFairThreshold) issues.push('move right');
|
|
403
|
+
else if (x > 0.5 + posFairThreshold) issues.push('move left');
|
|
404
|
+
|
|
405
|
+
// Vertical feedback (y=0 is bottom, y=1 is top)
|
|
406
|
+
if (y < 0.5 - posFairThreshold) issues.push('move up');
|
|
407
|
+
else if (y > 0.5 + posFairThreshold) issues.push('move down');
|
|
408
|
+
|
|
409
|
+
// Distance feedback (z=1 is close, z=0 is far)
|
|
410
|
+
const distFairThreshold = this.options.distanceThresholdFair;
|
|
411
|
+
if (z > 0.5 + distFairThreshold) issues.push('move back');
|
|
412
|
+
else if (z < 0.5 - distFairThreshold) issues.push('move closer');
|
|
413
|
+
|
|
414
|
+
if (issues.length > 0) {
|
|
415
|
+
feedback = `Please ${issues.join(' and ')}`;
|
|
416
|
+
} else {
|
|
417
|
+
feedback = 'Position: Almost there...';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
color =
|
|
421
|
+
quality.distanceStatus === 'poor' ||
|
|
422
|
+
quality.horizontalStatus === 'poor' ||
|
|
423
|
+
quality.verticalStatus === 'poor'
|
|
424
|
+
? this.options.poorColor
|
|
425
|
+
: this.options.fairColor;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
this.feedbackElement.textContent = feedback;
|
|
429
|
+
this.feedbackElement.style.color = color;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private assessPositionQuality(x: number, y: number, z: number): PositionQuality {
|
|
433
|
+
// Assess horizontal position (0.5 is center)
|
|
434
|
+
const xOffset = Math.abs(x - 0.5);
|
|
435
|
+
let horizontalStatus: 'good' | 'fair' | 'poor';
|
|
436
|
+
if (xOffset < this.options.positionThresholdGood) horizontalStatus = 'good';
|
|
437
|
+
else if (xOffset < this.options.positionThresholdFair) horizontalStatus = 'fair';
|
|
438
|
+
else horizontalStatus = 'poor';
|
|
439
|
+
|
|
440
|
+
// Assess vertical position (0.5 is center)
|
|
441
|
+
const yOffset = Math.abs(y - 0.5);
|
|
442
|
+
let verticalStatus: 'good' | 'fair' | 'poor';
|
|
443
|
+
if (yOffset < this.options.positionThresholdGood) verticalStatus = 'good';
|
|
444
|
+
else if (yOffset < this.options.positionThresholdFair) verticalStatus = 'fair';
|
|
445
|
+
else verticalStatus = 'poor';
|
|
446
|
+
|
|
447
|
+
// Assess distance (0.5 is optimal)
|
|
448
|
+
const zOffset = Math.abs(z - 0.5);
|
|
449
|
+
let distanceStatus: 'good' | 'fair' | 'poor';
|
|
450
|
+
if (zOffset < this.options.distanceThresholdGood) distanceStatus = 'good';
|
|
451
|
+
else if (zOffset < this.options.distanceThresholdFair) distanceStatus = 'fair';
|
|
452
|
+
else distanceStatus = 'poor';
|
|
453
|
+
|
|
454
|
+
const isGoodPosition =
|
|
455
|
+
horizontalStatus === 'good' && verticalStatus === 'good' && distanceStatus === 'good';
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
isGoodPosition,
|
|
459
|
+
horizontalStatus,
|
|
460
|
+
verticalStatus,
|
|
461
|
+
distanceStatus,
|
|
462
|
+
averageX: x,
|
|
463
|
+
averageY: y,
|
|
464
|
+
averageZ: z,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private showNoData(): void {
|
|
469
|
+
if (this.feedbackElement) {
|
|
470
|
+
this.feedbackElement.textContent = 'Waiting for tracker data...';
|
|
471
|
+
this.feedbackElement.style.color = this.options.poorColor;
|
|
472
|
+
}
|
|
473
|
+
this.faceOutlineElement.style.opacity = '0.3';
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Get current position quality
|
|
478
|
+
*/
|
|
479
|
+
public getCurrentQuality(positionData: UserPositionData | null): PositionQuality {
|
|
480
|
+
if (!positionData) {
|
|
481
|
+
return {
|
|
482
|
+
isGoodPosition: false,
|
|
483
|
+
horizontalStatus: 'poor',
|
|
484
|
+
verticalStatus: 'poor',
|
|
485
|
+
distanceStatus: 'poor',
|
|
486
|
+
averageX: null,
|
|
487
|
+
averageY: null,
|
|
488
|
+
averageZ: null,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const avgX = this.getAveragePosition(
|
|
493
|
+
positionData.leftX,
|
|
494
|
+
positionData.rightX,
|
|
495
|
+
positionData.leftValid,
|
|
496
|
+
positionData.rightValid
|
|
497
|
+
);
|
|
498
|
+
const avgY = this.getAveragePosition(
|
|
499
|
+
positionData.leftY,
|
|
500
|
+
positionData.rightY,
|
|
501
|
+
positionData.leftValid,
|
|
502
|
+
positionData.rightValid
|
|
503
|
+
);
|
|
504
|
+
const avgZ = this.getAveragePosition(
|
|
505
|
+
positionData.leftZ,
|
|
506
|
+
positionData.rightZ,
|
|
507
|
+
positionData.leftValid,
|
|
508
|
+
positionData.rightValid
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
if (avgX === null || avgY === null || avgZ === null) {
|
|
512
|
+
return {
|
|
513
|
+
isGoodPosition: false,
|
|
514
|
+
horizontalStatus: 'poor',
|
|
515
|
+
verticalStatus: 'poor',
|
|
516
|
+
distanceStatus: 'poor',
|
|
517
|
+
averageX: avgX,
|
|
518
|
+
averageY: avgY,
|
|
519
|
+
averageZ: avgZ,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return this.assessPositionQuality(avgX, avgY, avgZ);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Remove the display
|
|
528
|
+
*/
|
|
529
|
+
public destroy(): void {
|
|
530
|
+
this.container.innerHTML = '';
|
|
531
|
+
}
|
|
532
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export UserPositionData from extension.
|
|
3
|
+
* This mirrors the type defined in @jspsych/extension-tobii.
|
|
4
|
+
*/
|
|
5
|
+
export type { UserPositionData } from '@jspsych/extension-tobii';
|
|
6
|
+
/**
|
|
7
|
+
* Position quality assessment
|
|
8
|
+
*/
|
|
9
|
+
export interface PositionQuality {
|
|
10
|
+
isGoodPosition: boolean;
|
|
11
|
+
horizontalStatus: 'good' | 'fair' | 'poor';
|
|
12
|
+
verticalStatus: 'good' | 'fair' | 'poor';
|
|
13
|
+
distanceStatus: 'good' | 'fair' | 'poor';
|
|
14
|
+
averageX: number | null;
|
|
15
|
+
averageY: number | null;
|
|
16
|
+
averageZ: number | null;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,YAAY,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,cAAc,EAAE,OAAO,CAAC;IACxB,gBAAgB,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IAC3C,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACzC,cAAc,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IACzC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB"}
|
package/src/types.js
ADDED
package/src/types.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export UserPositionData from extension.
|
|
3
|
+
* This mirrors the type defined in @jspsych/extension-tobii.
|
|
4
|
+
*/
|
|
5
|
+
export type { UserPositionData } from '@jspsych/extension-tobii';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Position quality assessment
|
|
9
|
+
*/
|
|
10
|
+
export interface PositionQuality {
|
|
11
|
+
isGoodPosition: boolean;
|
|
12
|
+
horizontalStatus: 'good' | 'fair' | 'poor';
|
|
13
|
+
verticalStatus: 'good' | 'fair' | 'poor';
|
|
14
|
+
distanceStatus: 'good' | 'fair' | 'poor';
|
|
15
|
+
averageX: number | null;
|
|
16
|
+
averageY: number | null;
|
|
17
|
+
averageZ: number | null;
|
|
18
|
+
}
|