@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.
- package/README.md +220 -0
- package/dist/index.browser.js +1005 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.min.js +3 -0
- package/dist/index.browser.min.js.map +1 -0
- package/dist/index.cjs +1004 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +410 -0
- package/dist/index.js +1002 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
- package/src/coordinate-utils.d.ts +33 -0
- package/src/coordinate-utils.d.ts.map +1 -0
- package/src/coordinate-utils.js +70 -0
- package/src/coordinate-utils.js.map +1 -0
- package/src/coordinate-utils.ts +80 -0
- package/src/data-export.d.ts +12 -0
- package/src/data-export.d.ts.map +1 -0
- package/src/data-export.js +75 -0
- package/src/data-export.js.map +1 -0
- package/src/data-export.spec.d.ts +2 -0
- package/src/data-export.spec.d.ts.map +1 -0
- package/src/data-export.spec.js +95 -0
- package/src/data-export.spec.js.map +1 -0
- package/src/data-export.spec.ts +111 -0
- package/src/data-export.ts +84 -0
- package/src/data-manager.d.ts +57 -0
- package/src/data-manager.d.ts.map +1 -0
- package/src/data-manager.js +107 -0
- package/src/data-manager.js.map +1 -0
- package/src/data-manager.spec.d.ts +2 -0
- package/src/data-manager.spec.d.ts.map +1 -0
- package/src/data-manager.spec.js +162 -0
- package/src/data-manager.spec.js.map +1 -0
- package/src/data-manager.spec.ts +195 -0
- package/src/data-manager.ts +123 -0
- package/src/device-time-sync.d.ts +69 -0
- package/src/device-time-sync.d.ts.map +1 -0
- package/src/device-time-sync.js +150 -0
- package/src/device-time-sync.js.map +1 -0
- package/src/device-time-sync.ts +173 -0
- package/src/index.d.ts +200 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +431 -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 +212 -0
- package/src/index.spec.js.map +1 -0
- package/src/index.spec.ts +257 -0
- package/src/index.ts +535 -0
- package/src/time-sync.d.ts +39 -0
- package/src/time-sync.d.ts.map +1 -0
- package/src/time-sync.js +76 -0
- package/src/time-sync.js.map +1 -0
- package/src/time-sync.ts +91 -0
- package/src/types.d.ts +222 -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 +251 -0
- package/src/validation.d.ts +25 -0
- package/src/validation.d.ts.map +1 -0
- package/src/validation.js +54 -0
- package/src/validation.js.map +1 -0
- package/src/validation.ts +60 -0
- package/src/websocket-client.d.ts +55 -0
- package/src/websocket-client.d.ts.map +1 -0
- package/src/websocket-client.js +189 -0
- package/src/websocket-client.js.map +1 -0
- 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
|