@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/src/index.ts ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * @title Tobii User Position
3
+ * @description jsPsych plugin for Tobii eye tracker user position guide. Displays real-time
4
+ * head position feedback to help participants maintain optimal positioning for eye tracking.
5
+ * @version 1.0.0
6
+ * @author jsPsych Team
7
+ * @see {@link https://github.com/jspsych/jspsych-tobii/tree/main/packages/plugin-tobii-user-position#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 { PositionDisplay } from './position-display';
14
+ import type { PositionQuality } from './types';
15
+
16
+ const info = <const>{
17
+ name: 'tobii-user-position',
18
+ version: version,
19
+ parameters: {
20
+ /** Duration to show the position guide (ms), null for manual */
21
+ duration: {
22
+ type: ParameterType.INT,
23
+ default: null,
24
+ },
25
+ /** Message to display */
26
+ message: {
27
+ type: ParameterType.STRING,
28
+ default: 'Please position yourself so the indicators are green',
29
+ },
30
+ /** Update interval (ms) */
31
+ update_interval: {
32
+ type: ParameterType.INT,
33
+ default: 100,
34
+ },
35
+ /** Show distance feedback */
36
+ show_distance_feedback: {
37
+ type: ParameterType.BOOL,
38
+ default: true,
39
+ },
40
+ /** Show position feedback */
41
+ show_position_feedback: {
42
+ type: ParameterType.BOOL,
43
+ default: true,
44
+ },
45
+ /** Button text for manual continuation */
46
+ button_text: {
47
+ type: ParameterType.STRING,
48
+ default: 'Continue',
49
+ },
50
+ /** Only show button when position is good */
51
+ require_good_position: {
52
+ type: ParameterType.BOOL,
53
+ default: false,
54
+ },
55
+ /** Background color */
56
+ background_color: {
57
+ type: ParameterType.STRING,
58
+ default: '#f0f0f0',
59
+ },
60
+ /** Good position color */
61
+ good_color: {
62
+ type: ParameterType.STRING,
63
+ default: '#28a745',
64
+ },
65
+ /** Fair position color */
66
+ fair_color: {
67
+ type: ParameterType.STRING,
68
+ default: '#ffc107',
69
+ },
70
+ /** Poor position color */
71
+ poor_color: {
72
+ type: ParameterType.STRING,
73
+ default: '#dc3545',
74
+ },
75
+ /** Button color */
76
+ button_color: {
77
+ type: ParameterType.STRING,
78
+ default: '#007bff',
79
+ },
80
+ /** Button hover color */
81
+ button_hover_color: {
82
+ type: ParameterType.STRING,
83
+ default: '#0056b3',
84
+ },
85
+ /** Font size */
86
+ font_size: {
87
+ type: ParameterType.STRING,
88
+ default: '18px',
89
+ },
90
+ /** Position offset threshold for "good" status (normalized, default 0.15) */
91
+ position_threshold_good: {
92
+ type: ParameterType.FLOAT,
93
+ default: 0.15,
94
+ },
95
+ /** Position offset threshold for "fair" status (normalized, default 0.25) */
96
+ position_threshold_fair: {
97
+ type: ParameterType.FLOAT,
98
+ default: 0.25,
99
+ },
100
+ /** Distance offset threshold for "good" status (normalized, default 0.1) */
101
+ distance_threshold_good: {
102
+ type: ParameterType.FLOAT,
103
+ default: 0.1,
104
+ },
105
+ /** Distance offset threshold for "fair" status (normalized, default 0.2) */
106
+ distance_threshold_fair: {
107
+ type: ParameterType.FLOAT,
108
+ default: 0.2,
109
+ },
110
+ },
111
+ data: {
112
+ /** Average X position during trial */
113
+ average_x: {
114
+ type: ParameterType.FLOAT,
115
+ },
116
+ /** Average Y position during trial */
117
+ average_y: {
118
+ type: ParameterType.FLOAT,
119
+ },
120
+ /** Average Z position (distance) during trial */
121
+ average_z: {
122
+ type: ParameterType.FLOAT,
123
+ },
124
+ /** Whether position was good at end */
125
+ position_good: {
126
+ type: ParameterType.BOOL,
127
+ },
128
+ /** Horizontal position status */
129
+ horizontal_status: {
130
+ type: ParameterType.STRING,
131
+ },
132
+ /** Vertical position status */
133
+ vertical_status: {
134
+ type: ParameterType.STRING,
135
+ },
136
+ /** Distance status */
137
+ distance_status: {
138
+ type: ParameterType.STRING,
139
+ },
140
+ /** Duration of trial */
141
+ rt: {
142
+ type: ParameterType.INT,
143
+ },
144
+ },
145
+ };
146
+
147
+ type Info = typeof info;
148
+
149
+ class TobiiUserPositionPlugin implements JsPsychPlugin<Info> {
150
+ static info = info;
151
+
152
+ constructor(private jsPsych: JsPsych) {}
153
+
154
+ private static removeStyles(): void {
155
+ const el = document.getElementById('tobii-user-position-styles');
156
+ if (el) {
157
+ el.remove();
158
+ }
159
+ }
160
+
161
+ private injectStyles(trial: TrialType<Info>): void {
162
+ // Remove existing styles so each trial gets its own colors
163
+ TobiiUserPositionPlugin.removeStyles();
164
+
165
+ const css = `
166
+ .tobii-user-position-container {
167
+ display: flex;
168
+ flex-direction: column;
169
+ align-items: center;
170
+ justify-content: center;
171
+ width: 100%;
172
+ height: 100vh;
173
+ font-family: 'Open Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
174
+ font-size: ${trial.font_size};
175
+ }
176
+
177
+ .tobii-user-position-message {
178
+ margin-bottom: 40px;
179
+ text-align: center;
180
+ font-weight: 500;
181
+ color: #333;
182
+ }
183
+
184
+ .tobii-user-position-guide {
185
+ position: relative;
186
+ margin-bottom: 40px;
187
+ }
188
+
189
+ .tobii-position-feedback {
190
+ text-align: center;
191
+ margin-bottom: 30px;
192
+ font-weight: 600;
193
+ font-size: 1.1em;
194
+ }
195
+
196
+ .tobii-user-position-button {
197
+ padding: 12px 32px;
198
+ font-size: 16px;
199
+ font-weight: 500;
200
+ border: none;
201
+ border-radius: 6px;
202
+ background-color: ${trial.button_color};
203
+ color: white;
204
+ cursor: pointer;
205
+ transition: all 0.2s ease;
206
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
207
+ }
208
+
209
+ .tobii-user-position-button:hover:not(:disabled) {
210
+ background-color: ${trial.button_hover_color};
211
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
212
+ transform: translateY(-1px);
213
+ }
214
+
215
+ .tobii-user-position-button:disabled {
216
+ background-color: #ccc;
217
+ cursor: not-allowed;
218
+ opacity: 0.6;
219
+ }
220
+
221
+ .tobii-center-marker {
222
+ position: absolute;
223
+ top: 50%;
224
+ left: 50%;
225
+ transform: translate(-50%, -50%);
226
+ width: 30px;
227
+ height: 30px;
228
+ border: 2px dashed #666;
229
+ border-radius: 50%;
230
+ opacity: 0.5;
231
+ }
232
+ `;
233
+
234
+ const styleElement = document.createElement('style');
235
+ styleElement.id = 'tobii-user-position-styles';
236
+ styleElement.textContent = css;
237
+ document.head.appendChild(styleElement);
238
+ }
239
+
240
+ trial(display_element: HTMLElement, trial: TrialType<Info>) {
241
+ return new Promise<void>((resolve) => {
242
+ // Inject CSS
243
+ this.injectStyles(trial);
244
+
245
+ // Check for Tobii extension
246
+ const tobiiExtension = this.jsPsych.extensions.tobii as unknown as TobiiExtension;
247
+ if (!tobiiExtension) {
248
+ throw new Error('Tobii extension not loaded');
249
+ }
250
+
251
+ // Create container
252
+ display_element.innerHTML = `
253
+ <div class="tobii-user-position-container">
254
+ </div>
255
+ `;
256
+
257
+ const container = display_element.querySelector(
258
+ '.tobii-user-position-container'
259
+ ) as HTMLElement;
260
+
261
+ // Create position display
262
+ const positionDisplay = new PositionDisplay(container, {
263
+ message: trial.message!,
264
+ showDistanceFeedback: trial.show_distance_feedback!,
265
+ showPositionFeedback: trial.show_position_feedback!,
266
+ backgroundColor: trial.background_color!,
267
+ goodColor: trial.good_color!,
268
+ fairColor: trial.fair_color!,
269
+ poorColor: trial.poor_color!,
270
+ fontSize: trial.font_size!,
271
+ positionThresholdGood: trial.position_threshold_good!,
272
+ positionThresholdFair: trial.position_threshold_fair!,
273
+ distanceThresholdGood: trial.distance_threshold_good!,
274
+ distanceThresholdFair: trial.distance_threshold_fair!,
275
+ });
276
+
277
+ // Add continue button if no duration specified
278
+ let continueButton: HTMLButtonElement | null = null;
279
+ if (trial.duration === null) {
280
+ continueButton = document.createElement('button');
281
+ continueButton.className = 'tobii-user-position-button';
282
+ continueButton.textContent = trial.button_text!;
283
+ if (trial.require_good_position) {
284
+ continueButton.disabled = true;
285
+ }
286
+ container.appendChild(continueButton);
287
+ }
288
+
289
+ // Track position data
290
+ const positionSamples: PositionQuality[] = [];
291
+ const startTime = performance.now();
292
+
293
+ // Update position display periodically
294
+ const updateInterval = setInterval(async () => {
295
+ try {
296
+ const positionData = await tobiiExtension.getUserPosition();
297
+ positionDisplay.updatePosition(positionData);
298
+
299
+ // Track position quality
300
+ const quality = positionDisplay.getCurrentQuality(positionData);
301
+ positionSamples.push(quality);
302
+
303
+ // Update button state if required
304
+ if (continueButton && trial.require_good_position) {
305
+ continueButton.disabled = !quality.isGoodPosition;
306
+ }
307
+ } catch (error) {
308
+ console.error('Error updating user position:', error);
309
+ }
310
+ }, trial.update_interval!);
311
+
312
+ // Cleanup helper to ensure DOM and styles are always cleaned up
313
+ const cleanup = () => {
314
+ clearInterval(updateInterval);
315
+ positionDisplay.destroy();
316
+ display_element.innerHTML = '';
317
+ TobiiUserPositionPlugin.removeStyles();
318
+ };
319
+
320
+ // Handle trial end
321
+ const endTrial = () => {
322
+ // Calculate average position
323
+ const validSamples = positionSamples.filter(
324
+ (s) => s.averageX !== null && s.averageY !== null && s.averageZ !== null
325
+ );
326
+
327
+ let averageX = null;
328
+ let averageY = null;
329
+ let averageZ = null;
330
+ let finalQuality: PositionQuality | null = null;
331
+
332
+ if (validSamples.length > 0) {
333
+ averageX = validSamples.reduce((sum, s) => sum + s.averageX!, 0) / validSamples.length;
334
+ averageY = validSamples.reduce((sum, s) => sum + s.averageY!, 0) / validSamples.length;
335
+ averageZ = validSamples.reduce((sum, s) => sum + s.averageZ!, 0) / validSamples.length;
336
+ finalQuality = positionSamples[positionSamples.length - 1];
337
+ }
338
+
339
+ const trialData = {
340
+ average_x: averageX,
341
+ average_y: averageY,
342
+ average_z: averageZ,
343
+ position_good: finalQuality?.isGoodPosition ?? false,
344
+ horizontal_status: finalQuality?.horizontalStatus ?? 'poor',
345
+ vertical_status: finalQuality?.verticalStatus ?? 'poor',
346
+ distance_status: finalQuality?.distanceStatus ?? 'poor',
347
+ rt: Math.round(performance.now() - startTime),
348
+ };
349
+
350
+ cleanup();
351
+ this.jsPsych.finishTrial(trialData);
352
+ resolve();
353
+ };
354
+
355
+ // Set up continue button
356
+ if (continueButton) {
357
+ continueButton.addEventListener('click', endTrial);
358
+ }
359
+
360
+ // Set up duration timeout
361
+ if (trial.duration != null) {
362
+ this.jsPsych.pluginAPI.setTimeout(endTrial, trial.duration);
363
+ }
364
+ });
365
+ }
366
+ }
367
+
368
+ export default TobiiUserPositionPlugin;
@@ -0,0 +1,59 @@
1
+ import { UserPositionData, PositionQuality } from './types';
2
+ export interface PositionDisplayOptions {
3
+ message: string;
4
+ showDistanceFeedback: boolean;
5
+ showPositionFeedback: boolean;
6
+ backgroundColor: string;
7
+ goodColor: string;
8
+ fairColor: string;
9
+ poorColor: string;
10
+ fontSize: string;
11
+ positionThresholdGood: number;
12
+ positionThresholdFair: number;
13
+ distanceThresholdGood: number;
14
+ distanceThresholdFair: number;
15
+ }
16
+ /**
17
+ * Display component for user position guide
18
+ * Shows a face outline that scales with distance, similar to Tobii Eye Tracker Manager
19
+ */
20
+ export declare class PositionDisplay {
21
+ private container;
22
+ private messageElement;
23
+ private trackingBoxElement;
24
+ private faceOutlineElement;
25
+ private leftEyeElement;
26
+ private rightEyeElement;
27
+ private distanceBarContainer;
28
+ private distanceBarFill;
29
+ private feedbackElement;
30
+ private options;
31
+ private readonly BOX_WIDTH;
32
+ private readonly BOX_HEIGHT;
33
+ private readonly MIN_FACE_SCALE;
34
+ private readonly MAX_FACE_SCALE;
35
+ private readonly OPTIMAL_FACE_SCALE;
36
+ constructor(container: HTMLElement, options: PositionDisplayOptions);
37
+ private createDisplay;
38
+ private createFaceSVG;
39
+ /**
40
+ * Update the display with new position data
41
+ */
42
+ updatePosition(positionData: UserPositionData | null): void;
43
+ private getAveragePosition;
44
+ private updateFaceDisplay;
45
+ private updateDistanceBar;
46
+ private updateEyeIndicators;
47
+ private updateTextualFeedback;
48
+ private assessPositionQuality;
49
+ private showNoData;
50
+ /**
51
+ * Get current position quality
52
+ */
53
+ getCurrentQuality(positionData: UserPositionData | null): PositionQuality;
54
+ /**
55
+ * Remove the display
56
+ */
57
+ destroy(): void;
58
+ }
59
+ //# sourceMappingURL=position-display.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"position-display.d.ts","sourceRoot":"","sources":["position-display.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAE5D,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,oBAAoB,EAAE,OAAO,CAAC;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,qBAAqB,EAAE,MAAM,CAAC;IAC9B,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AAED;;;GAGG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,SAAS,CAAc;IAC/B,OAAO,CAAC,cAAc,CAAe;IACrC,OAAO,CAAC,kBAAkB,CAAe;IACzC,OAAO,CAAC,kBAAkB,CAAe;IACzC,OAAO,CAAC,cAAc,CAAe;IACrC,OAAO,CAAC,eAAe,CAAe;IACtC,OAAO,CAAC,oBAAoB,CAAe;IAC3C,OAAO,CAAC,eAAe,CAAe;IACtC,OAAO,CAAC,eAAe,CAAe;IACtC,OAAO,CAAC,OAAO,CAAyB;IAGxC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAO;IACjC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAO;IAClC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAO;IACtC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAO;IACtC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAO;gBAE9B,SAAS,EAAE,WAAW,EAAE,OAAO,EAAE,sBAAsB;IAMnE,OAAO,CAAC,aAAa;IA8IrB,OAAO,CAAC,aAAa;IA4DrB;;OAEG;IACI,cAAc,CAAC,YAAY,EAAE,gBAAgB,GAAG,IAAI,GAAG,IAAI;IAyClE,OAAO,CAAC,kBAAkB;IAgB1B,OAAO,CAAC,iBAAiB;IAgCzB,OAAO,CAAC,iBAAiB;IAwBzB,OAAO,CAAC,mBAAmB;IAc3B,OAAO,CAAC,qBAAqB;IAqD7B,OAAO,CAAC,qBAAqB;IAoC7B,OAAO,CAAC,UAAU;IAQlB;;OAEG;IACI,iBAAiB,CAAC,YAAY,EAAE,gBAAgB,GAAG,IAAI,GAAG,eAAe;IA+ChF;;OAEG;IACI,OAAO,IAAI,IAAI;CAGvB"}