@jspsych/extension-tobii 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.
Files changed (71) hide show
  1. package/README.md +220 -0
  2. package/dist/index.browser.js +1005 -0
  3. package/dist/index.browser.js.map +1 -0
  4. package/dist/index.browser.min.js +3 -0
  5. package/dist/index.browser.min.js.map +1 -0
  6. package/dist/index.cjs +1004 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.ts +410 -0
  9. package/dist/index.js +1002 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +52 -0
  12. package/src/coordinate-utils.d.ts +33 -0
  13. package/src/coordinate-utils.d.ts.map +1 -0
  14. package/src/coordinate-utils.js +70 -0
  15. package/src/coordinate-utils.js.map +1 -0
  16. package/src/coordinate-utils.ts +80 -0
  17. package/src/data-export.d.ts +12 -0
  18. package/src/data-export.d.ts.map +1 -0
  19. package/src/data-export.js +75 -0
  20. package/src/data-export.js.map +1 -0
  21. package/src/data-export.spec.d.ts +2 -0
  22. package/src/data-export.spec.d.ts.map +1 -0
  23. package/src/data-export.spec.js +95 -0
  24. package/src/data-export.spec.js.map +1 -0
  25. package/src/data-export.spec.ts +111 -0
  26. package/src/data-export.ts +84 -0
  27. package/src/data-manager.d.ts +57 -0
  28. package/src/data-manager.d.ts.map +1 -0
  29. package/src/data-manager.js +107 -0
  30. package/src/data-manager.js.map +1 -0
  31. package/src/data-manager.spec.d.ts +2 -0
  32. package/src/data-manager.spec.d.ts.map +1 -0
  33. package/src/data-manager.spec.js +162 -0
  34. package/src/data-manager.spec.js.map +1 -0
  35. package/src/data-manager.spec.ts +195 -0
  36. package/src/data-manager.ts +123 -0
  37. package/src/device-time-sync.d.ts +69 -0
  38. package/src/device-time-sync.d.ts.map +1 -0
  39. package/src/device-time-sync.js +150 -0
  40. package/src/device-time-sync.js.map +1 -0
  41. package/src/device-time-sync.ts +173 -0
  42. package/src/index.d.ts +200 -0
  43. package/src/index.d.ts.map +1 -0
  44. package/src/index.js +431 -0
  45. package/src/index.js.map +1 -0
  46. package/src/index.spec.d.ts +2 -0
  47. package/src/index.spec.d.ts.map +1 -0
  48. package/src/index.spec.js +212 -0
  49. package/src/index.spec.js.map +1 -0
  50. package/src/index.spec.ts +257 -0
  51. package/src/index.ts +535 -0
  52. package/src/time-sync.d.ts +39 -0
  53. package/src/time-sync.d.ts.map +1 -0
  54. package/src/time-sync.js +76 -0
  55. package/src/time-sync.js.map +1 -0
  56. package/src/time-sync.ts +91 -0
  57. package/src/types.d.ts +222 -0
  58. package/src/types.d.ts.map +1 -0
  59. package/src/types.js +5 -0
  60. package/src/types.js.map +1 -0
  61. package/src/types.ts +251 -0
  62. package/src/validation.d.ts +25 -0
  63. package/src/validation.d.ts.map +1 -0
  64. package/src/validation.js +54 -0
  65. package/src/validation.js.map +1 -0
  66. package/src/validation.ts +60 -0
  67. package/src/websocket-client.d.ts +55 -0
  68. package/src/websocket-client.d.ts.map +1 -0
  69. package/src/websocket-client.js +189 -0
  70. package/src/websocket-client.js.map +1 -0
  71. package/src/websocket-client.ts +227 -0
@@ -0,0 +1,1005 @@
1
+ var jsPsychExtensionTobii = (function (jspsych) {
2
+ 'use strict';
3
+
4
+ var version = "0.1.1";
5
+
6
+ class WebSocketClient {
7
+ constructor(config = {}) {
8
+ this.ws = null;
9
+ this.reconnectTimeout = null;
10
+ this.currentReconnectAttempt = 0;
11
+ this.nextRequestId = 0;
12
+ this.config = {
13
+ url: config.url || "ws://localhost:8080",
14
+ autoConnect: config.autoConnect ?? true,
15
+ reconnectAttempts: config.reconnectAttempts ?? 5,
16
+ reconnectDelay: config.reconnectDelay ?? 1e3
17
+ };
18
+ this.status = {
19
+ connected: false,
20
+ tracking: false
21
+ };
22
+ this.messageHandlers = /* @__PURE__ */ new Map();
23
+ }
24
+ /**
25
+ * Connect to WebSocket server
26
+ */
27
+ async connect() {
28
+ if (this.ws?.readyState === WebSocket.OPEN) {
29
+ return;
30
+ }
31
+ return new Promise((resolve, reject) => {
32
+ try {
33
+ this.ws = new WebSocket(this.config.url);
34
+ const timeoutId = setTimeout(() => {
35
+ if (this.ws?.readyState !== WebSocket.OPEN) {
36
+ reject(new Error(`Connection timeout (${this.config.url})`));
37
+ }
38
+ }, 5e3);
39
+ this.ws.onopen = () => {
40
+ clearTimeout(timeoutId);
41
+ this.status.connected = true;
42
+ this.status.connectedAt = Date.now();
43
+ this.currentReconnectAttempt = 0;
44
+ resolve();
45
+ };
46
+ this.ws.onmessage = (event) => {
47
+ this.handleMessage(event);
48
+ };
49
+ this.ws.onerror = (error) => {
50
+ this.status.lastError = "WebSocket error";
51
+ console.error("WebSocket error:", error);
52
+ };
53
+ this.ws.onclose = () => {
54
+ this.status.connected = false;
55
+ this.status.tracking = false;
56
+ this.handleDisconnect();
57
+ };
58
+ } catch (error) {
59
+ reject(error);
60
+ }
61
+ });
62
+ }
63
+ /**
64
+ * Disconnect from WebSocket server
65
+ */
66
+ async disconnect() {
67
+ if (this.reconnectTimeout !== null) {
68
+ clearTimeout(this.reconnectTimeout);
69
+ this.reconnectTimeout = null;
70
+ }
71
+ if (this.ws) {
72
+ this.ws.close();
73
+ this.ws = null;
74
+ }
75
+ this.status.connected = false;
76
+ this.status.tracking = false;
77
+ }
78
+ /**
79
+ * Check if connected
80
+ */
81
+ isConnected() {
82
+ return this.ws?.readyState === WebSocket.OPEN;
83
+ }
84
+ /**
85
+ * Get connection status
86
+ */
87
+ getStatus() {
88
+ return { ...this.status };
89
+ }
90
+ /**
91
+ * Send message to server
92
+ */
93
+ async send(message) {
94
+ if (!this.isConnected()) {
95
+ throw new Error("Not connected to server");
96
+ }
97
+ this.ws.send(JSON.stringify(message));
98
+ }
99
+ /**
100
+ * Send message and wait for response
101
+ */
102
+ async sendAndWait(message, timeout = 5e3) {
103
+ if (!this.isConnected()) {
104
+ throw new Error("Not connected to server");
105
+ }
106
+ return new Promise((resolve, reject) => {
107
+ const requestId = `req_${this.nextRequestId++}`;
108
+ const messageWithId = { ...message, requestId };
109
+ const timeoutId = setTimeout(() => {
110
+ this.messageHandlers.delete(requestId);
111
+ reject(new Error("Request timeout"));
112
+ }, timeout);
113
+ this.messageHandlers.set(requestId, (data) => {
114
+ clearTimeout(timeoutId);
115
+ this.messageHandlers.delete(requestId);
116
+ resolve(data);
117
+ });
118
+ this.ws.send(JSON.stringify(messageWithId));
119
+ });
120
+ }
121
+ /**
122
+ * Register message handler
123
+ */
124
+ on(messageType, handler) {
125
+ if (this.messageHandlers.has(messageType)) {
126
+ console.warn(`Tobii WebSocket: Overwriting existing handler for message type "${messageType}"`);
127
+ }
128
+ this.messageHandlers.set(messageType, handler);
129
+ }
130
+ /**
131
+ * Unregister message handler
132
+ */
133
+ off(messageType) {
134
+ this.messageHandlers.delete(messageType);
135
+ }
136
+ /**
137
+ * Handle incoming message
138
+ */
139
+ handleMessage(event) {
140
+ try {
141
+ const receiveTime = performance.now();
142
+ const data = JSON.parse(event.data);
143
+ data._clientReceiveTime = receiveTime;
144
+ if (data.requestId && this.messageHandlers.has(data.requestId)) {
145
+ const handler = this.messageHandlers.get(data.requestId);
146
+ handler(data);
147
+ return;
148
+ }
149
+ if (data.type && this.messageHandlers.has(data.type)) {
150
+ const handler = this.messageHandlers.get(data.type);
151
+ handler(data);
152
+ }
153
+ } catch (error) {
154
+ console.error("Error handling message:", error);
155
+ }
156
+ }
157
+ /**
158
+ * Handle disconnection and attempt reconnect
159
+ */
160
+ handleDisconnect() {
161
+ if (this.currentReconnectAttempt < this.config.reconnectAttempts) {
162
+ this.currentReconnectAttempt++;
163
+ const delay = this.config.reconnectDelay * this.currentReconnectAttempt;
164
+ this.reconnectTimeout = window.setTimeout(async () => {
165
+ try {
166
+ await this.connect();
167
+ const reconnectedHandler = this.messageHandlers.get("reconnected");
168
+ if (reconnectedHandler) {
169
+ reconnectedHandler({ type: "reconnected" });
170
+ }
171
+ } catch (error) {
172
+ console.warn(`Tobii: Reconnection attempt ${this.currentReconnectAttempt}/${this.config.reconnectAttempts} failed:`, error);
173
+ }
174
+ }, delay);
175
+ } else {
176
+ this.status.lastError = "Max reconnection attempts reached";
177
+ }
178
+ }
179
+ }
180
+
181
+ class DataManager {
182
+ /**
183
+ * @param maxBufferSize Maximum number of samples to retain. Oldest samples
184
+ * are dropped when the buffer exceeds this size. Default is 7200
185
+ * (~60 seconds at 120 Hz).
186
+ */
187
+ constructor(maxBufferSize = 7200) {
188
+ this.gazeBuffer = [];
189
+ this.trialStartTime = null;
190
+ this.trialEndTime = null;
191
+ this.maxBufferSize = maxBufferSize;
192
+ }
193
+ /**
194
+ * Add gaze data point to the buffer
195
+ */
196
+ addGazeData(data) {
197
+ this.gazeBuffer.push(data);
198
+ if (this.gazeBuffer.length > this.maxBufferSize) {
199
+ this.gazeBuffer = this.gazeBuffer.slice(-this.maxBufferSize);
200
+ }
201
+ }
202
+ /**
203
+ * Mark trial start
204
+ */
205
+ startTrial() {
206
+ this.trialStartTime = performance.now();
207
+ }
208
+ /**
209
+ * Mark trial end
210
+ */
211
+ endTrial() {
212
+ this.trialEndTime = performance.now();
213
+ }
214
+ /**
215
+ * Get all gaze data for current trial
216
+ */
217
+ getTrialData() {
218
+ if (this.trialStartTime === null) {
219
+ return [];
220
+ }
221
+ const endTime = this.trialEndTime || performance.now();
222
+ return this.gazeBuffer.filter((data) => {
223
+ const ts = data.browserTimestamp ?? data.timestamp;
224
+ return ts >= this.trialStartTime && ts <= endTime;
225
+ });
226
+ }
227
+ /**
228
+ * Get gaze data for specific time range (using browserTimestamp if available)
229
+ */
230
+ getDataRange(startTime, endTime) {
231
+ return this.gazeBuffer.filter((data) => {
232
+ const ts = data.browserTimestamp ?? data.timestamp;
233
+ return ts >= startTime && ts <= endTime;
234
+ });
235
+ }
236
+ /**
237
+ * Get most recent gaze data point
238
+ */
239
+ getCurrentGaze() {
240
+ if (this.gazeBuffer.length === 0) {
241
+ return null;
242
+ }
243
+ return this.gazeBuffer[this.gazeBuffer.length - 1];
244
+ }
245
+ /**
246
+ * Clear all gaze data
247
+ */
248
+ clear() {
249
+ this.gazeBuffer = [];
250
+ this.trialStartTime = null;
251
+ this.trialEndTime = null;
252
+ }
253
+ /**
254
+ * Clear old data (keep only recent data)
255
+ */
256
+ clearOldData(keepDuration = 6e4) {
257
+ const cutoffTime = performance.now() - keepDuration;
258
+ this.gazeBuffer = this.gazeBuffer.filter((data) => {
259
+ const ts = data.browserTimestamp ?? data.timestamp;
260
+ return ts >= cutoffTime;
261
+ });
262
+ }
263
+ /**
264
+ * Get buffer size
265
+ */
266
+ getBufferSize() {
267
+ return this.gazeBuffer.length;
268
+ }
269
+ /**
270
+ * Get recent gaze data from the last N milliseconds
271
+ */
272
+ getRecentData(durationMs) {
273
+ const now = performance.now();
274
+ const startTime = now - durationMs;
275
+ return this.gazeBuffer.filter((data) => {
276
+ const ts = data.browserTimestamp ?? data.timestamp;
277
+ return ts >= startTime;
278
+ });
279
+ }
280
+ }
281
+
282
+ class TimeSync {
283
+ constructor(ws) {
284
+ this.ws = ws;
285
+ this.offset = 0;
286
+ this.synced = false;
287
+ }
288
+ /**
289
+ * Synchronize time with server
290
+ */
291
+ async synchronize() {
292
+ const measurements = [];
293
+ const numSamples = 10;
294
+ for (let i = 0; i < numSamples; i++) {
295
+ const t0 = performance.now();
296
+ const response = await this.ws.sendAndWait({
297
+ type: "time_sync",
298
+ clientTime: t0
299
+ });
300
+ const t1 = performance.now();
301
+ const roundTripTime = t1 - t0;
302
+ const serverTime = response.serverTime;
303
+ const latency = roundTripTime / 2;
304
+ const offset = serverTime - (t0 + latency);
305
+ measurements.push(offset);
306
+ await this.delay(100);
307
+ }
308
+ this.offset = this.median(measurements);
309
+ this.synced = true;
310
+ }
311
+ /**
312
+ * Convert local timestamp to server timestamp
313
+ */
314
+ toServerTime(localTime) {
315
+ return localTime + this.offset;
316
+ }
317
+ /**
318
+ * Convert server timestamp to local timestamp
319
+ */
320
+ toLocalTime(serverTime) {
321
+ return serverTime - this.offset;
322
+ }
323
+ /**
324
+ * Check if time is synchronized
325
+ */
326
+ isSynced() {
327
+ return this.synced;
328
+ }
329
+ /**
330
+ * Get current offset
331
+ */
332
+ getOffset() {
333
+ return this.offset;
334
+ }
335
+ /**
336
+ * Calculate median of array
337
+ */
338
+ median(values) {
339
+ const sorted = [...values].sort((a, b) => a - b);
340
+ const mid = Math.floor(sorted.length / 2);
341
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
342
+ }
343
+ /**
344
+ * Delay helper
345
+ */
346
+ delay(ms) {
347
+ return new Promise((resolve) => setTimeout(resolve, ms));
348
+ }
349
+ }
350
+
351
+ class DeviceTimeSync {
352
+ constructor(ws, timeSync) {
353
+ this.ws = ws;
354
+ this.timeSync = timeSync;
355
+ this.offsetBC = null;
356
+ this.bcSampleCount = 0;
357
+ this.bcStdDev = null;
358
+ this.bcMin = null;
359
+ this.bcMax = null;
360
+ }
361
+ /**
362
+ * Request the B-C offset from the server and compute the A-C chain.
363
+ * Requires that TimeSync (A-B) is already synchronized and that
364
+ * gaze samples have been collected on the server.
365
+ */
366
+ async synchronizeDeviceClock() {
367
+ if (!this.timeSync.isSynced()) {
368
+ return false;
369
+ }
370
+ try {
371
+ const response = await this.ws.sendAndWait({
372
+ type: "get_device_clock_offset"
373
+ });
374
+ if (!response.success) {
375
+ return false;
376
+ }
377
+ this.offsetBC = response.offset;
378
+ this.bcSampleCount = response.sample_count;
379
+ this.bcStdDev = response.std_dev;
380
+ this.bcMin = response.min;
381
+ this.bcMax = response.max;
382
+ return true;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+ /**
388
+ * Whether the full A↔C chain is established
389
+ */
390
+ isSynced() {
391
+ return this.timeSync.isSynced() && this.offsetBC !== null;
392
+ }
393
+ /**
394
+ * Convert a performance.now() timestamp to device clock time.
395
+ * offset_AB: B = A + offset_AB
396
+ * offset_BC: B = C + offset_BC → C = B - offset_BC
397
+ * So: C = (A + offset_AB) - offset_BC = A + (offset_AB - offset_BC)
398
+ */
399
+ toDeviceTime(performanceNow) {
400
+ if (!this.isSynced()) {
401
+ throw new Error("Device time sync not established. Call synchronizeDeviceClock() first.");
402
+ }
403
+ const offsetAB = this.timeSync.getOffset();
404
+ return performanceNow + offsetAB - this.offsetBC;
405
+ }
406
+ /**
407
+ * Convert a device clock timestamp to performance.now() domain.
408
+ * A = C - offset_AB + offset_BC = C - (offset_AB - offset_BC)
409
+ */
410
+ toLocalTime(deviceTime) {
411
+ if (!this.isSynced()) {
412
+ throw new Error("Device time sync not established. Call synchronizeDeviceClock() first.");
413
+ }
414
+ const offsetAB = this.timeSync.getOffset();
415
+ return deviceTime - offsetAB + this.offsetBC;
416
+ }
417
+ /**
418
+ * Get full synchronization status with all offsets and diagnostics
419
+ */
420
+ getStatus() {
421
+ const offsetAB = this.timeSync.getOffset();
422
+ const offsetAC = this.offsetBC !== null ? offsetAB - this.offsetBC : null;
423
+ return {
424
+ synced: this.isSynced(),
425
+ offsetAB,
426
+ offsetBC: this.offsetBC,
427
+ offsetAC,
428
+ bcSampleCount: this.bcSampleCount,
429
+ bcStdDev: this.bcStdDev,
430
+ bcMin: this.bcMin,
431
+ bcMax: this.bcMax
432
+ };
433
+ }
434
+ /**
435
+ * Validate timestamp alignment across a set of gaze samples.
436
+ *
437
+ * For each sample, computes: residual = (_receiveTime + offset_AC) - timestamp
438
+ * using the internal _receiveTime property (raw WebSocket receive time) as an
439
+ * independent measurement to cross-validate the sync offset.
440
+ * If clocks are well-aligned, residuals should cluster tightly around the
441
+ * one-way WebSocket latency (server→client).
442
+ *
443
+ * @param samples - Array of gaze samples (must have internal _receiveTime set)
444
+ * @returns Alignment statistics, or null if sync is not established or no valid samples
445
+ */
446
+ validateTimestampAlignment(samples) {
447
+ if (!this.isSynced()) {
448
+ return null;
449
+ }
450
+ const offsetAB = this.timeSync.getOffset();
451
+ const offsetAC = offsetAB - this.offsetBC;
452
+ const residuals = [];
453
+ for (const sample of samples) {
454
+ const receiveTime = sample._receiveTime;
455
+ if (receiveTime != null && sample.timestamp != null) {
456
+ residuals.push(receiveTime + offsetAC - sample.timestamp);
457
+ }
458
+ }
459
+ if (residuals.length === 0) {
460
+ return null;
461
+ }
462
+ const n = residuals.length;
463
+ const mean = residuals.reduce((a, b) => a + b, 0) / n;
464
+ const variance = residuals.reduce((a, b) => a + (b - mean) ** 2, 0) / n;
465
+ const stdDev = Math.sqrt(variance);
466
+ const min = Math.min(...residuals);
467
+ const max = Math.max(...residuals);
468
+ return {
469
+ sampleCount: n,
470
+ meanResidual: mean,
471
+ stdDev,
472
+ min,
473
+ max
474
+ };
475
+ }
476
+ /**
477
+ * Reset sync state (e.g., after reconnection)
478
+ */
479
+ reset() {
480
+ this.offsetBC = null;
481
+ this.bcSampleCount = 0;
482
+ this.bcStdDev = null;
483
+ this.bcMin = null;
484
+ this.bcMax = null;
485
+ }
486
+ }
487
+
488
+ function normalizedToPixels(x, y) {
489
+ const width = window.innerWidth;
490
+ const height = window.innerHeight;
491
+ return {
492
+ x: Math.round(x * width),
493
+ y: Math.round(y * height)
494
+ };
495
+ }
496
+ function pixelsToNormalized(x, y) {
497
+ const width = window.innerWidth;
498
+ const height = window.innerHeight;
499
+ return {
500
+ x: x / width,
501
+ y: y / height
502
+ };
503
+ }
504
+ function getScreenDimensions() {
505
+ return {
506
+ width: window.innerWidth,
507
+ height: window.innerHeight
508
+ };
509
+ }
510
+ function distance(p1, p2) {
511
+ const dx = p2.x - p1.x;
512
+ const dy = p2.y - p1.y;
513
+ return Math.sqrt(dx * dx + dy * dy);
514
+ }
515
+ function windowToContainer(x, y, container) {
516
+ const rect = container.getBoundingClientRect();
517
+ return {
518
+ x: Math.round(x - rect.left),
519
+ y: Math.round(y - rect.top)
520
+ };
521
+ }
522
+ function getContainerDimensions(container) {
523
+ const rect = container.getBoundingClientRect();
524
+ return {
525
+ width: rect.width,
526
+ height: rect.height
527
+ };
528
+ }
529
+ function isWithinContainer(x, y, container) {
530
+ const rect = container.getBoundingClientRect();
531
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
532
+ }
533
+
534
+ function toCSV(data, filename) {
535
+ if (data.length === 0) {
536
+ console.warn("No data to export");
537
+ return;
538
+ }
539
+ const keys = Array.from(new Set(data.flatMap((item) => Object.keys(flattenObject(item)))));
540
+ const header = keys.join(",");
541
+ const rows = data.map((item) => {
542
+ const flattened = flattenObject(item);
543
+ return keys.map((key) => {
544
+ const value = flattened[key];
545
+ if (typeof value === "string" && value.includes(",")) {
546
+ return `"${value}"`;
547
+ }
548
+ return value ?? "";
549
+ }).join(",");
550
+ });
551
+ const csv = [header, ...rows].join("\n");
552
+ downloadFile(csv, filename, "text/csv");
553
+ }
554
+ function toJSON(data, filename) {
555
+ const json = JSON.stringify(data, null, 2);
556
+ downloadFile(json, filename, "application/json");
557
+ }
558
+ function flattenObject(obj, prefix = "") {
559
+ const flattened = {};
560
+ for (const [key, value] of Object.entries(obj)) {
561
+ const newKey = prefix ? `${prefix}.${key}` : key;
562
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
563
+ Object.assign(flattened, flattenObject(value, newKey));
564
+ } else if (Array.isArray(value)) {
565
+ flattened[newKey] = JSON.stringify(value);
566
+ } else {
567
+ flattened[newKey] = value;
568
+ }
569
+ }
570
+ return flattened;
571
+ }
572
+ function downloadFile(content, filename, mimeType) {
573
+ const blob = new Blob([content], { type: mimeType });
574
+ const url = URL.createObjectURL(blob);
575
+ const link = document.createElement("a");
576
+ link.href = url;
577
+ link.download = filename;
578
+ document.body.appendChild(link);
579
+ link.click();
580
+ document.body.removeChild(link);
581
+ setTimeout(() => URL.revokeObjectURL(url), 6e4);
582
+ }
583
+
584
+ function validateGazeData(data) {
585
+ if (typeof data !== "object" || data === null)
586
+ return false;
587
+ const d = data;
588
+ return typeof d.x === "number" && typeof d.y === "number" && typeof d.timestamp === "number" && !isNaN(d.x) && !isNaN(d.y) && !isNaN(d.timestamp);
589
+ }
590
+ function validateCalibrationPoint(point) {
591
+ if (typeof point !== "object" || point === null)
592
+ return false;
593
+ const p = point;
594
+ return typeof p.x === "number" && typeof p.y === "number" && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1;
595
+ }
596
+ function filterValidGaze(data) {
597
+ return data.filter(validateGazeData);
598
+ }
599
+ function validateCalibrationResult(data) {
600
+ if (typeof data !== "object" || data === null)
601
+ return false;
602
+ return typeof data.success === "boolean";
603
+ }
604
+ function validateValidationResult(data) {
605
+ if (typeof data !== "object" || data === null)
606
+ return false;
607
+ return typeof data.success === "boolean";
608
+ }
609
+
610
+ class TobiiExtension {
611
+ constructor(jsPsych) {
612
+ this.tracking = false;
613
+ this.config = {};
614
+ this.gazeSampleCount = 0;
615
+ this.deviceTimeSyncTriggered = false;
616
+ this.initialize = async (params = {}) => {
617
+ this.config = params;
618
+ this.ws = new WebSocketClient(params.connection);
619
+ this.dataManager = new DataManager();
620
+ this.timeSync = new TimeSync(this.ws);
621
+ this.deviceTimeSync = new DeviceTimeSync(this.ws, this.timeSync);
622
+ this.ws.on("gaze_data", (data) => {
623
+ const rawGaze = data.gaze;
624
+ if (rawGaze && validateGazeData(rawGaze)) {
625
+ const receiveTime = data._clientReceiveTime ?? performance.now();
626
+ const gazeWithTimestamps = {
627
+ ...rawGaze,
628
+ browserTimestamp: this.deviceTimeSync.isSynced() ? this.deviceTimeSync.toLocalTime(rawGaze.timestamp) : receiveTime
629
+ };
630
+ gazeWithTimestamps._receiveTime = receiveTime;
631
+ this.dataManager.addGazeData(gazeWithTimestamps);
632
+ this.gazeSampleCount++;
633
+ if (!this.deviceTimeSyncTriggered && this.gazeSampleCount >= 50) {
634
+ this.deviceTimeSyncTriggered = true;
635
+ this.deviceTimeSync.synchronizeDeviceClock().catch((e) => {
636
+ console.warn("Tobii: Device time sync failed, can be retried manually:", e);
637
+ });
638
+ }
639
+ }
640
+ });
641
+ this.ws.on("reconnected", async () => {
642
+ try {
643
+ await this.timeSync.synchronize();
644
+ } catch (e) {
645
+ console.warn("Tobii: Time sync failed after reconnection:", e);
646
+ }
647
+ this.deviceTimeSync.reset();
648
+ this.gazeSampleCount = 0;
649
+ this.deviceTimeSyncTriggered = false;
650
+ });
651
+ if (params.connection?.autoConnect) {
652
+ await this.connect();
653
+ }
654
+ };
655
+ this.on_start = async (_params = {}) => {
656
+ this.dataManager.startTrial();
657
+ if (!this.tracking) {
658
+ await this.startTracking();
659
+ }
660
+ };
661
+ this.on_load = async () => {
662
+ };
663
+ this.on_finish = async (_params = {}) => {
664
+ this.dataManager.endTrial();
665
+ const trialData = this.dataManager.getTrialData();
666
+ this.dataManager.clearOldData();
667
+ return {
668
+ tobii_data: trialData
669
+ };
670
+ };
671
+ this.jsPsych = jsPsych;
672
+ }
673
+ static {
674
+ this.info = {
675
+ name: "tobii",
676
+ version,
677
+ data: {
678
+ /** Eye tracking gaze data collected during the trial */
679
+ tobii_data: {
680
+ type: jspsych.ParameterType.COMPLEX,
681
+ array: true
682
+ }
683
+ }
684
+ };
685
+ }
686
+ // ==========================================
687
+ // PUBLIC API METHODS
688
+ // These are accessible via jsPsych.extensions.tobii.*
689
+ // ==========================================
690
+ /**
691
+ * Connect to the WebSocket server
692
+ */
693
+ async connect() {
694
+ await this.ws.connect();
695
+ await this.timeSync.synchronize();
696
+ }
697
+ /**
698
+ * Disconnect from the WebSocket server
699
+ */
700
+ async disconnect() {
701
+ if (this.tracking) {
702
+ await this.stopTracking();
703
+ }
704
+ await this.ws.disconnect();
705
+ }
706
+ /**
707
+ * Check if connected to server
708
+ */
709
+ isConnected() {
710
+ return this.ws.isConnected();
711
+ }
712
+ /**
713
+ * Get connection status details
714
+ */
715
+ getConnectionStatus() {
716
+ return this.ws.getStatus();
717
+ }
718
+ /**
719
+ * Start eye tracking data collection
720
+ */
721
+ async startTracking() {
722
+ if (!this.isConnected()) {
723
+ throw new Error("Not connected to server. Call connect() first.");
724
+ }
725
+ const response = await this.ws.sendAndWait({ type: "start_tracking" });
726
+ if (response.success) {
727
+ this.tracking = true;
728
+ } else {
729
+ throw new Error(`Server failed to start tracking: ${response.error || "unknown error"}`);
730
+ }
731
+ }
732
+ /**
733
+ * Stop eye tracking data collection
734
+ */
735
+ async stopTracking() {
736
+ try {
737
+ await this.ws.sendAndWait({ type: "stop_tracking" });
738
+ } finally {
739
+ this.tracking = false;
740
+ }
741
+ }
742
+ /**
743
+ * Check if currently tracking
744
+ */
745
+ isTracking() {
746
+ return this.tracking;
747
+ }
748
+ /**
749
+ * Start calibration procedure
750
+ */
751
+ async startCalibration() {
752
+ await this.ws.send({ type: "calibration_start" });
753
+ }
754
+ /**
755
+ * Collect calibration data for a specific point
756
+ * @returns Promise resolving to success status when SDK finishes collecting
757
+ */
758
+ async collectCalibrationPoint(x, y) {
759
+ if (!validateCalibrationPoint({ x, y })) {
760
+ throw new Error(
761
+ `Invalid calibration point (${x}, ${y}). Coordinates must be in range [0, 1].`
762
+ );
763
+ }
764
+ const response = await this.ws.sendAndWait({
765
+ type: "calibration_point",
766
+ point: { x, y },
767
+ timestamp: performance.now()
768
+ });
769
+ return { success: response.success === true };
770
+ }
771
+ /**
772
+ * Compute calibration from collected points
773
+ */
774
+ async computeCalibration() {
775
+ const response = await this.ws.sendAndWait({
776
+ type: "calibration_compute"
777
+ });
778
+ if (!validateCalibrationResult(response)) {
779
+ return { success: false, error: "Invalid server response" };
780
+ }
781
+ return response;
782
+ }
783
+ /**
784
+ * Get calibration data/quality metrics
785
+ */
786
+ async getCalibrationData() {
787
+ const response = await this.ws.sendAndWait({
788
+ type: "get_calibration_data"
789
+ });
790
+ if (!validateCalibrationResult(response)) {
791
+ return { success: false, error: "Invalid server response" };
792
+ }
793
+ return response;
794
+ }
795
+ /**
796
+ * Start validation procedure
797
+ */
798
+ async startValidation() {
799
+ await this.ws.send({ type: "validation_start" });
800
+ }
801
+ /**
802
+ * Collect validation data for a specific point
803
+ * @param x - Normalized x coordinate (0-1)
804
+ * @param y - Normalized y coordinate (0-1)
805
+ * @param gazeSamples - Optional array of gaze samples collected at this point
806
+ */
807
+ async collectValidationPoint(x, y, gazeSamples) {
808
+ if (!validateCalibrationPoint({ x, y })) {
809
+ throw new Error(
810
+ `Invalid validation point (${x}, ${y}). Coordinates must be in range [0, 1].`
811
+ );
812
+ }
813
+ await this.ws.send({
814
+ type: "validation_point",
815
+ point: { x, y },
816
+ timestamp: performance.now(),
817
+ gaze_samples: gazeSamples || []
818
+ });
819
+ }
820
+ /**
821
+ * Get recent gaze data from the data manager buffer
822
+ * @param durationMs - How many milliseconds of recent data to retrieve
823
+ */
824
+ getRecentGazeData(durationMs) {
825
+ return this.dataManager.getRecentData(durationMs);
826
+ }
827
+ /**
828
+ * Compute validation from collected points
829
+ */
830
+ async computeValidation() {
831
+ const response = await this.ws.sendAndWait({
832
+ type: "validation_compute"
833
+ });
834
+ if (!validateValidationResult(response)) {
835
+ return { success: false, error: "Invalid server response" };
836
+ }
837
+ return response;
838
+ }
839
+ /**
840
+ * Get current gaze position
841
+ */
842
+ async getCurrentGaze() {
843
+ const localGaze = this.dataManager.getCurrentGaze();
844
+ if (localGaze) {
845
+ return localGaze;
846
+ }
847
+ const response = await this.ws.sendAndWait({
848
+ type: "get_current_gaze"
849
+ });
850
+ return response.gaze || null;
851
+ }
852
+ /**
853
+ * Get current user position (head position)
854
+ */
855
+ async getUserPosition() {
856
+ if (!this.isConnected()) {
857
+ return null;
858
+ }
859
+ const response = await this.ws.sendAndWait({
860
+ type: "get_user_position"
861
+ });
862
+ return response.position || null;
863
+ }
864
+ /**
865
+ * Get gaze data for a specific time range
866
+ */
867
+ async getGazeData(startTime, endTime) {
868
+ const localData = this.dataManager.getDataRange(startTime, endTime);
869
+ return filterValidGaze(localData);
870
+ }
871
+ /**
872
+ * Clear stored gaze data
873
+ */
874
+ clearGazeData() {
875
+ this.dataManager.clear();
876
+ }
877
+ /**
878
+ * Convert normalized coordinates (0-1) to pixels
879
+ */
880
+ normalizedToPixels(x, y) {
881
+ return normalizedToPixels(x, y);
882
+ }
883
+ /**
884
+ * Convert pixel coordinates to normalized (0-1)
885
+ */
886
+ pixelsToNormalized(x, y) {
887
+ return pixelsToNormalized(x, y);
888
+ }
889
+ /**
890
+ * Get screen dimensions
891
+ */
892
+ getScreenDimensions() {
893
+ return getScreenDimensions();
894
+ }
895
+ /**
896
+ * Calculate distance between two points
897
+ */
898
+ calculateDistance(p1, p2) {
899
+ return distance(p1, p2);
900
+ }
901
+ /**
902
+ * Convert window pixel coordinates to container-relative coordinates
903
+ * @param x - X coordinate in window pixels
904
+ * @param y - Y coordinate in window pixels
905
+ * @param container - Optional container element (defaults to jsPsych display element)
906
+ */
907
+ windowToContainer(x, y, container) {
908
+ const el = container || this.jsPsych.getDisplayElement();
909
+ return windowToContainer(x, y, el);
910
+ }
911
+ /**
912
+ * Get container dimensions
913
+ * @param container - Optional container element (defaults to jsPsych display element)
914
+ */
915
+ getContainerDimensions(container) {
916
+ const el = container || this.jsPsych.getDisplayElement();
917
+ return getContainerDimensions(el);
918
+ }
919
+ /**
920
+ * Check if window coordinates fall within a container
921
+ * @param x - X coordinate in window pixels
922
+ * @param y - Y coordinate in window pixels
923
+ * @param container - Optional container element (defaults to jsPsych display element)
924
+ */
925
+ isWithinContainer(x, y, container) {
926
+ const el = container || this.jsPsych.getDisplayElement();
927
+ return isWithinContainer(x, y, el);
928
+ }
929
+ /**
930
+ * Export gaze data to CSV
931
+ */
932
+ exportToCSV(data, filename) {
933
+ toCSV(data, filename);
934
+ }
935
+ /**
936
+ * Export gaze data to JSON
937
+ */
938
+ exportToJSON(data, filename) {
939
+ toJSON(data, filename);
940
+ }
941
+ /**
942
+ * Set extension configuration
943
+ */
944
+ setConfig(config) {
945
+ this.config = { ...this.config, ...config };
946
+ }
947
+ /**
948
+ * Get current configuration
949
+ */
950
+ getConfig() {
951
+ return { ...this.config };
952
+ }
953
+ /**
954
+ * Get time synchronization offset
955
+ */
956
+ getTimeOffset() {
957
+ return this.timeSync.getOffset();
958
+ }
959
+ /**
960
+ * Check if time is synchronized
961
+ */
962
+ isTimeSynced() {
963
+ return this.timeSync.isSynced();
964
+ }
965
+ /**
966
+ * Convert a performance.now() timestamp to Tobii device clock time.
967
+ * Requires that device time sync is established.
968
+ */
969
+ toDeviceTime(performanceNow) {
970
+ return this.deviceTimeSync.toDeviceTime(performanceNow);
971
+ }
972
+ /**
973
+ * Convert a Tobii device clock timestamp to performance.now() domain.
974
+ * Requires that device time sync is established.
975
+ */
976
+ toLocalTime(deviceTime) {
977
+ return this.deviceTimeSync.toLocalTime(deviceTime);
978
+ }
979
+ /**
980
+ * Check if the browser-to-device time sync chain is established
981
+ */
982
+ isDeviceTimeSynced() {
983
+ return this.deviceTimeSync.isSynced();
984
+ }
985
+ /**
986
+ * Get full device time synchronization status with all offsets and diagnostics
987
+ */
988
+ getTimeSyncStatus() {
989
+ return this.deviceTimeSync.getStatus();
990
+ }
991
+ /**
992
+ * Validate timestamp alignment across a set of gaze samples.
993
+ * Computes per-sample residuals to verify the A↔C offset is consistent.
994
+ * Low stdDev indicates well-aligned timestamps.
995
+ * @param samples - Gaze samples to validate (uses internal _receiveTime for cross-check)
996
+ */
997
+ validateTimestampAlignment(samples) {
998
+ return this.deviceTimeSync.validateTimestampAlignment(samples);
999
+ }
1000
+ }
1001
+
1002
+ return TobiiExtension;
1003
+
1004
+ })(jsPsychModule);
1005
+ //# sourceMappingURL=https://unpkg.com/@jspsych/extension-tobii@0.1.1/dist/index.browser.js.map