@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.
@@ -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
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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
+ }