@probat/react 0.1.5 → 0.2.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,665 @@
1
+ /**
2
+ * Universal Heatmap Tracker for User Websites
3
+ *
4
+ * Tracks all clicks across the entire user website for heatmap visualization.
5
+ * This is injected into user websites via the probat-react package.
6
+ */
7
+
8
+ interface ClickEvent {
9
+ page_url: string;
10
+ site_url: string;
11
+ x_coordinate: number;
12
+ y_coordinate: number;
13
+ viewport_width: number;
14
+ viewport_height: number;
15
+ element_tag: string | null;
16
+ element_class: string | null;
17
+ element_id: string | null;
18
+ session_id: string;
19
+ proposal_id?: string | null;
20
+ variant_label?: string | null;
21
+ }
22
+
23
+ interface CursorMovementEvent {
24
+ page_url: string;
25
+ site_url: string;
26
+ x_coordinate: number;
27
+ y_coordinate: number;
28
+ viewport_width: number;
29
+ viewport_height: number;
30
+ session_id: string;
31
+ proposal_id?: string | null;
32
+ variant_label?: string | null;
33
+ }
34
+
35
+ interface HeatmapConfig {
36
+ apiBaseUrl: string;
37
+ batchSize?: number;
38
+ batchInterval?: number;
39
+ enabled?: boolean;
40
+ excludeSelectors?: string[];
41
+ trackCursor?: boolean; // Enable cursor movement tracking
42
+ cursorThrottle?: number; // Milliseconds between cursor movement captures (default: 100ms)
43
+ cursorBatchSize?: number; // Number of cursor movements to batch before sending
44
+ proposalId?: string; // Optional proposal/experiment id
45
+ variantLabel?: string; // Optional variant label
46
+ }
47
+
48
+ function getStoredExperimentInfo(): { proposalId?: string; variantLabel?: string } {
49
+ if (typeof window === 'undefined') return {};
50
+ try {
51
+ const proposalId = window.localStorage.getItem('probat_active_proposal_id') || undefined;
52
+ const variantLabel = window.localStorage.getItem('probat_active_variant_label') || undefined;
53
+ return { proposalId, variantLabel };
54
+ } catch {
55
+ return {};
56
+ }
57
+ }
58
+
59
+ class HeatmapTracker {
60
+ private clicks: ClickEvent[] = [];
61
+ private movements: CursorMovementEvent[] = [];
62
+ private sessionId: string;
63
+ private config: HeatmapConfig;
64
+ private batchTimer: ReturnType<typeof setTimeout> | null = null;
65
+ private cursorBatchTimer: ReturnType<typeof setTimeout> | null = null;
66
+ private lastCursorTime = 0;
67
+ private isInitialized = false;
68
+
69
+ constructor(config: HeatmapConfig) {
70
+ this.sessionId = this.getOrCreateSessionId();
71
+ const stored = getStoredExperimentInfo();
72
+
73
+ this.config = {
74
+ apiBaseUrl: config.apiBaseUrl,
75
+ batchSize: config.batchSize || 10,
76
+ batchInterval: config.batchInterval || 5000,
77
+ enabled: config.enabled !== false,
78
+ excludeSelectors: config.excludeSelectors || [
79
+ '[data-heatmap-exclude]',
80
+ 'input[type="password"]',
81
+ 'input[type="email"]',
82
+ 'textarea',
83
+ ],
84
+ trackCursor: config.trackCursor !== false, // Enable cursor tracking by default
85
+ cursorThrottle: config.cursorThrottle || 100, // Capture cursor position every 100ms
86
+ cursorBatchSize: config.cursorBatchSize || 50, // Send every 50 movements
87
+ proposalId: config.proposalId || stored.proposalId,
88
+ variantLabel: config.variantLabel || stored.variantLabel,
89
+ };
90
+ }
91
+
92
+ private getOrCreateSessionId(): string {
93
+ if (typeof window === 'undefined') return '';
94
+
95
+ const storageKey = 'probat_heatmap_session_id';
96
+ let sessionId = localStorage.getItem(storageKey);
97
+
98
+ if (!sessionId) {
99
+ sessionId = `heatmap_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
100
+ localStorage.setItem(storageKey, sessionId);
101
+ }
102
+
103
+ return sessionId;
104
+ }
105
+
106
+ private shouldExcludeElement(element: HTMLElement): boolean {
107
+ if (!this.config.excludeSelectors) return false;
108
+
109
+ for (const selector of this.config.excludeSelectors) {
110
+ if (element.matches(selector) || element.closest(selector)) {
111
+ return true;
112
+ }
113
+ }
114
+
115
+ return false;
116
+ }
117
+
118
+ private extractElementInfo(element: HTMLElement): {
119
+ tag: string | null;
120
+ class: string | null;
121
+ id: string | null;
122
+ } {
123
+ return {
124
+ tag: element.tagName || null,
125
+ class: element.className && typeof element.className === 'string' ? element.className : null,
126
+ id: element.id || null,
127
+ };
128
+ }
129
+
130
+ private handleMouseMove = (event: MouseEvent): void => {
131
+ if (!this.config.enabled || !this.config.trackCursor) return;
132
+ if (typeof window === 'undefined') return;
133
+
134
+ // Refresh proposal/variant from localStorage at runtime
135
+ const stored = getStoredExperimentInfo();
136
+
137
+ // If stored info matches current proposal, or we don't have a proposal yet, use stored variant
138
+ const activeProposalId = this.config.proposalId || stored.proposalId;
139
+ const activeVariantLabel = (stored.proposalId === activeProposalId)
140
+ ? (stored.variantLabel || this.config.variantLabel)
141
+ : (this.config.variantLabel || stored.variantLabel);
142
+
143
+ // Throttle cursor tracking
144
+ const now = Date.now();
145
+ const cursorThrottle = this.config.cursorThrottle ?? 100;
146
+ if (now - this.lastCursorTime < cursorThrottle) {
147
+ return;
148
+ }
149
+ this.lastCursorTime = now;
150
+
151
+ // Debug: Log first few movements to verify it's working
152
+ console.log('[PROBAT Heatmap] Cursor movement captured:', {
153
+ x: event.pageX,
154
+ y: event.pageY,
155
+ movementsInBatch: this.movements.length + 1
156
+ });
157
+
158
+ // Get page information
159
+ const pageUrl = window.location.href;
160
+ const siteUrl = window.location.origin;
161
+
162
+ // Get cursor coordinates relative to document (including scroll)
163
+ const x = event.pageX;
164
+ const y = event.pageY;
165
+
166
+ // Get viewport dimensions
167
+ const viewportWidth = window.innerWidth;
168
+ const viewportHeight = window.innerHeight;
169
+
170
+ // Create cursor movement event
171
+ const movementEvent: CursorMovementEvent = {
172
+ page_url: pageUrl,
173
+ site_url: siteUrl,
174
+ x_coordinate: x,
175
+ y_coordinate: y,
176
+ viewport_width: viewportWidth,
177
+ viewport_height: viewportHeight,
178
+ session_id: this.sessionId,
179
+ proposal_id: activeProposalId,
180
+ variant_label: activeVariantLabel,
181
+ };
182
+
183
+ // Add to batch
184
+ this.movements.push(movementEvent);
185
+
186
+ // Send batch if it reaches the size limit
187
+ const cursorBatchSize = this.config.cursorBatchSize ?? 50;
188
+ if (this.movements.length >= cursorBatchSize) {
189
+ this.sendCursorBatch();
190
+ } else {
191
+ // Otherwise, schedule a send after the interval
192
+ this.scheduleCursorBatchSend();
193
+ }
194
+ };
195
+
196
+ private scheduleCursorBatchSend(): void {
197
+ // Clear existing timer
198
+ if (this.cursorBatchTimer) {
199
+ clearTimeout(this.cursorBatchTimer);
200
+ }
201
+
202
+ // Schedule new send
203
+ this.cursorBatchTimer = setTimeout(() => {
204
+ if (this.movements.length > 0) {
205
+ this.sendCursorBatch();
206
+ }
207
+ }, this.config.batchInterval ?? 5000);
208
+ }
209
+
210
+ private async sendCursorBatch(): Promise<void> {
211
+ if (this.movements.length === 0) return;
212
+
213
+ // Get site URL from first movement
214
+ const siteUrl = this.movements[0]?.site_url || window.location.origin;
215
+
216
+ // Create batch
217
+ const batch = {
218
+ movements: this.movements,
219
+ site_url: siteUrl,
220
+ proposal_id: this.config.proposalId,
221
+ variant_label: this.config.variantLabel,
222
+ };
223
+
224
+ // Clear movements array
225
+ this.movements = [];
226
+
227
+ // Clear timer
228
+ if (this.cursorBatchTimer) {
229
+ clearTimeout(this.cursorBatchTimer);
230
+ this.cursorBatchTimer = null;
231
+ }
232
+
233
+ try {
234
+ console.log('[PROBAT Heatmap] Sending cursor movements batch:', {
235
+ count: batch.movements.length,
236
+ site_url: batch.site_url
237
+ });
238
+
239
+ // Send to backend
240
+ const response = await fetch(`${this.config.apiBaseUrl}/api/heatmap/cursor-movements`, {
241
+ method: 'POST',
242
+ headers: {
243
+ 'Content-Type': 'application/json',
244
+ },
245
+ body: JSON.stringify(batch),
246
+ // Don't wait for response - fire and forget for performance
247
+ });
248
+
249
+ if (!response.ok) {
250
+ console.warn('[PROBAT Heatmap] Failed to send cursor movements:', response.status);
251
+ } else {
252
+ console.log('[PROBAT Heatmap] Successfully sent cursor movements batch');
253
+ }
254
+ } catch (error) {
255
+ // Silently fail - don't interrupt user experience
256
+ console.warn('[PROBAT Heatmap] Error sending cursor movements:', error);
257
+ }
258
+ }
259
+
260
+ private handleClick = (event: MouseEvent): void => {
261
+ if (!this.config.enabled) return;
262
+ if (typeof window === 'undefined') return;
263
+
264
+ // Refresh proposal/variant from localStorage at runtime
265
+ const stored = getStoredExperimentInfo();
266
+
267
+ // Priority: use localStorage variant if it matches the current proposal
268
+ const activeProposalId = this.config.proposalId || stored.proposalId;
269
+ const activeVariantLabel = (stored.proposalId === activeProposalId)
270
+ ? (stored.variantLabel || this.config.variantLabel)
271
+ : (this.config.variantLabel || stored.variantLabel);
272
+
273
+ const target = event.target as HTMLElement | null;
274
+ if (!target) return;
275
+
276
+ if (this.shouldExcludeElement(target)) {
277
+ return;
278
+ }
279
+
280
+ const pageUrl = window.location.href;
281
+ const siteUrl = window.location.origin;
282
+
283
+ // Get click coordinates relative to document (including scroll)
284
+ const x = event.pageX;
285
+ const y = event.pageY;
286
+
287
+ const viewportWidth = window.innerWidth;
288
+ const viewportHeight = window.innerHeight;
289
+
290
+ const elementInfo = this.extractElementInfo(target);
291
+
292
+ const clickEvent: ClickEvent = {
293
+ page_url: pageUrl,
294
+ site_url: siteUrl,
295
+ x_coordinate: x,
296
+ y_coordinate: y,
297
+ viewport_width: viewportWidth,
298
+ viewport_height: viewportHeight,
299
+ element_tag: elementInfo.tag,
300
+ element_class: elementInfo.class,
301
+ element_id: elementInfo.id,
302
+ session_id: this.sessionId,
303
+ proposal_id: activeProposalId,
304
+ variant_label: activeVariantLabel,
305
+ };
306
+
307
+ this.clicks.push(clickEvent);
308
+
309
+ const batchSize = this.config.batchSize ?? 10;
310
+ if (this.clicks.length >= batchSize) {
311
+ this.sendBatch();
312
+ } else {
313
+ this.scheduleBatchSend();
314
+ }
315
+ };
316
+
317
+ private scheduleBatchSend(): void {
318
+ if (this.batchTimer) {
319
+ clearTimeout(this.batchTimer);
320
+ }
321
+
322
+ this.batchTimer = setTimeout(() => {
323
+ if (this.clicks.length > 0) {
324
+ this.sendBatch();
325
+ }
326
+ }, this.config.batchInterval ?? 5000);
327
+ }
328
+
329
+ private async sendBatch(): Promise<void> {
330
+ if (this.clicks.length === 0) return;
331
+
332
+ if (this.batchTimer) {
333
+ clearTimeout(this.batchTimer);
334
+ this.batchTimer = null;
335
+ }
336
+
337
+ const siteUrl = this.clicks[0]?.site_url || window.location.origin;
338
+
339
+ const batch = {
340
+ clicks: [...this.clicks],
341
+ site_url: siteUrl,
342
+ };
343
+
344
+ this.clicks = [];
345
+
346
+ try {
347
+ const response = await fetch(`${this.config.apiBaseUrl}/api/heatmap/clicks`, {
348
+ method: 'POST',
349
+ headers: {
350
+ 'Content-Type': 'application/json',
351
+ },
352
+ body: JSON.stringify(batch),
353
+ });
354
+
355
+ if (!response.ok) {
356
+ console.warn('[PROBAT Heatmap] Failed to send clicks:', response.status);
357
+ }
358
+ } catch (error) {
359
+ console.warn('[PROBAT Heatmap] Error sending clicks:', error);
360
+ }
361
+ }
362
+
363
+ public init(): void {
364
+ if (this.isInitialized) {
365
+ console.warn('[PROBAT Heatmap] Tracker already initialized');
366
+ return;
367
+ }
368
+
369
+ if (typeof window === 'undefined') {
370
+ return;
371
+ }
372
+
373
+ // Check for heatmap visualization mode FIRST
374
+ const params = new URLSearchParams(window.location.search);
375
+ if (params.get('heatmap') === 'true') {
376
+ console.log('[PROBAT Heatmap] Heatmap visualization mode detected');
377
+ const pageUrl = params.get('page_url');
378
+ if (pageUrl) {
379
+ this.config.enabled = false;
380
+ this.isInitialized = true;
381
+ this.enableVisualization(pageUrl);
382
+ return;
383
+ }
384
+ }
385
+
386
+ document.addEventListener('click', this.handleClick, true);
387
+
388
+ // Attach cursor movement listener if enabled
389
+ if (this.config.trackCursor) {
390
+ document.addEventListener('mousemove', this.handleMouseMove, { passive: true });
391
+ console.log('[PROBAT Heatmap] Cursor tracking enabled');
392
+ } else {
393
+ console.log('[PROBAT Heatmap] Cursor tracking disabled');
394
+ }
395
+
396
+ this.isInitialized = true;
397
+ console.log('[PROBAT Heatmap] Tracker initialized');
398
+
399
+ window.addEventListener('beforeunload', () => {
400
+ if (this.clicks.length > 0) {
401
+ const siteUrl = this.clicks[0]?.site_url || window.location.origin;
402
+ const batch = {
403
+ clicks: this.clicks,
404
+ site_url: siteUrl,
405
+ };
406
+
407
+ const blob = new Blob([JSON.stringify(batch)], {
408
+ type: 'application/json',
409
+ });
410
+ navigator.sendBeacon(
411
+ `${this.config.apiBaseUrl}/api/heatmap/clicks`,
412
+ blob
413
+ );
414
+ }
415
+
416
+ if (this.movements.length > 0) {
417
+ const siteUrl = this.movements[0]?.site_url || window.location.origin;
418
+ const batch = {
419
+ movements: this.movements,
420
+ site_url: siteUrl,
421
+ };
422
+
423
+ const blob = new Blob([JSON.stringify(batch)], {
424
+ type: 'application/json',
425
+ });
426
+ navigator.sendBeacon(
427
+ `${this.config.apiBaseUrl}/api/heatmap/cursor-movements`,
428
+ blob
429
+ );
430
+ }
431
+ });
432
+ }
433
+
434
+ public stop(): void {
435
+ if (!this.isInitialized) return;
436
+
437
+ // Send any pending clicks
438
+ if (this.clicks.length > 0) {
439
+ this.sendBatch();
440
+ }
441
+
442
+ // Send any pending cursor movements
443
+ if (this.movements.length > 0) {
444
+ this.sendCursorBatch();
445
+ }
446
+
447
+ // Remove event listeners
448
+ document.removeEventListener('click', this.handleClick, true);
449
+ if (this.config.trackCursor) {
450
+ document.removeEventListener('mousemove', this.handleMouseMove);
451
+ }
452
+
453
+ // Clear timers
454
+ if (this.batchTimer) {
455
+ clearTimeout(this.batchTimer);
456
+ this.batchTimer = null;
457
+ }
458
+ if (this.cursorBatchTimer) {
459
+ clearTimeout(this.cursorBatchTimer);
460
+ this.cursorBatchTimer = null;
461
+ }
462
+
463
+ this.isInitialized = false;
464
+ }
465
+
466
+ /**
467
+ * Enable visualization mode
468
+ */
469
+ /**
470
+ * Enable visualization mode
471
+ */
472
+ private async enableVisualization(pageUrl: string): Promise<void> {
473
+ console.log('[PROBAT Heatmap] Enabling visualization mode for:', pageUrl);
474
+
475
+ // Stop tracking just in case
476
+ this.stop();
477
+ this.config.enabled = false;
478
+
479
+ try {
480
+ // Fetch heatmap data
481
+ const url = new URL(`${this.config.apiBaseUrl}/api/heatmap/aggregate`);
482
+ url.searchParams.append('site_url', window.location.origin);
483
+ url.searchParams.append('page_url', pageUrl);
484
+ url.searchParams.append('days', '30'); // Default to 30 days
485
+
486
+ // Add variant filtering if present in URL
487
+ const params = new URLSearchParams(window.location.search);
488
+ const proposalId = params.get('proposal_id');
489
+ const variantLabel = params.get('variant_label');
490
+
491
+ if (proposalId) url.searchParams.append('proposal_id', proposalId);
492
+ if (variantLabel) url.searchParams.append('variant_label', variantLabel);
493
+
494
+ const response = await fetch(url.toString());
495
+ if (!response.ok) throw new Error('Failed to fetch heatmap data');
496
+
497
+ const data = await response.json();
498
+ if (data && data.points) {
499
+ console.log(`[PROBAT Heatmap] Found ${data.points.length} points. Rendering...`);
500
+ this.renderHeatmapOverlay(data);
501
+ }
502
+ } catch (error) {
503
+ console.error('[PROBAT Heatmap] Visualization error:', error);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Render heatmap overlay on valid points
509
+ */
510
+ private renderHeatmapOverlay(data: any): void {
511
+ const points = data.points;
512
+ const trackedWidth = data.viewport_width || 0;
513
+
514
+ // Remove existing heatmap if any
515
+ const existing = document.getElementById('probat-heatmap-overlay');
516
+ if (existing) existing.remove();
517
+
518
+ const canvas = document.createElement('canvas');
519
+ canvas.id = 'probat-heatmap-overlay';
520
+ canvas.style.position = 'absolute';
521
+ canvas.style.top = '0';
522
+ canvas.style.left = '0';
523
+ canvas.style.zIndex = '999999';
524
+ canvas.style.pointerEvents = 'none';
525
+ canvas.style.display = 'block';
526
+ canvas.style.margin = '0';
527
+ canvas.style.padding = '0';
528
+
529
+ // Append to html to ensure it's relative to the absolute document top-left (0,0)
530
+ document.documentElement.appendChild(canvas);
531
+
532
+ const resize = () => {
533
+ const dpr = window.devicePixelRatio || 1;
534
+ const width = document.documentElement.scrollWidth;
535
+ const height = document.documentElement.scrollHeight;
536
+ const currentWidth = window.innerWidth;
537
+
538
+ // Horizontal centering adjustment
539
+ // Formula: current_x = tracked_x + (current_viewport_width - tracked_viewport_width) / 2
540
+ let offsetX = 0;
541
+ if (trackedWidth > 0 && trackedWidth !== currentWidth) {
542
+ offsetX = (currentWidth - trackedWidth) / 2;
543
+ console.log(`[PROBAT Heatmap] Horizontal adjustment: offset=${offsetX}px (Tracked: ${trackedWidth}px, Current: ${currentWidth}px)`);
544
+ }
545
+
546
+ // Set display size
547
+ canvas.style.width = width + 'px';
548
+ canvas.style.height = height + 'px';
549
+
550
+ // Set internal resolution scaled by pixel ratio
551
+ canvas.width = width * dpr;
552
+ canvas.height = height * dpr;
553
+
554
+ const ctx = canvas.getContext('2d');
555
+ if (ctx) {
556
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // Clear and set scale in one go
557
+ this.renderPoints(ctx, points, offsetX);
558
+ }
559
+ };
560
+
561
+ window.addEventListener('resize', resize);
562
+ // Call multiple times to catch delayed layout shifts
563
+ setTimeout(resize, 300);
564
+ setTimeout(resize, 1000);
565
+ setTimeout(resize, 3000);
566
+ }
567
+
568
+ /**
569
+ * Draw points on canvas
570
+ */
571
+ private renderPoints(ctx: CanvasRenderingContext2D, points: any[], offsetX: number): void {
572
+ points.forEach(point => {
573
+ // Apply horizontal offset for centered layouts
574
+ const x = point.x + offsetX;
575
+ const y = point.y;
576
+ const intensity = point.intensity;
577
+
578
+ // Scale radius based on intensity
579
+ const radius = 20 + (intensity * 12);
580
+ const color = this.getHeatmapColor(intensity);
581
+
582
+ // Use radial gradient for a "heat" look
583
+ const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
584
+ gradient.addColorStop(0, this.rgbToRgba(color, 0.8));
585
+ gradient.addColorStop(1, this.rgbToRgba(color, 0));
586
+
587
+ ctx.beginPath();
588
+ ctx.arc(x, y, radius, 0, 2 * Math.PI);
589
+ ctx.fillStyle = gradient;
590
+ ctx.fill();
591
+ });
592
+ }
593
+
594
+ /**
595
+ * Get heatmap color based on intensity
596
+ */
597
+ private getHeatmapColor(intensity: number): string {
598
+ const clamped = Math.max(0, Math.min(1, intensity));
599
+
600
+ if (clamped < 0.25) {
601
+ const t = clamped / 0.25;
602
+ const r = Math.floor(0 + t * 0);
603
+ const g = Math.floor(0 + t * 255);
604
+ const b = Math.floor(255 + t * (255 - 255));
605
+ return `rgb(${r}, ${g}, ${b})`;
606
+ } else if (clamped < 0.5) {
607
+ const t = (clamped - 0.25) / 0.25;
608
+ const r = Math.floor(0 + t * 0);
609
+ const g = 255;
610
+ const b = Math.floor(255 + t * (0 - 255));
611
+ return `rgb(${r}, ${g}, ${b})`;
612
+ } else if (clamped < 0.75) {
613
+ const t = (clamped - 0.5) / 0.25;
614
+ const r = Math.floor(0 + t * 255);
615
+ const g = 255;
616
+ const b = 0;
617
+ return `rgb(${r}, ${g}, ${b})`;
618
+ } else {
619
+ const t = (clamped - 0.75) / 0.25;
620
+ const r = 255;
621
+ const g = Math.floor(255 + t * (0 - 255));
622
+ const b = 0;
623
+ return `rgb(${r}, ${g}, ${b})`;
624
+ }
625
+ }
626
+
627
+ /**
628
+ * Convert RGB to RGBA
629
+ */
630
+ private rgbToRgba(rgb: string, opacity: number): string {
631
+ const match = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
632
+ if (match) {
633
+ return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${opacity})`;
634
+ }
635
+ return rgb;
636
+ }
637
+ }
638
+
639
+ let trackerInstance: HeatmapTracker | null = null;
640
+
641
+ /**
642
+ * Initialize heatmap tracking for user websites
643
+ * Called automatically by ProbatProvider
644
+ */
645
+ export function initHeatmapTracking(config: HeatmapConfig): HeatmapTracker {
646
+ if (trackerInstance) {
647
+ trackerInstance.stop();
648
+ }
649
+
650
+ trackerInstance = new HeatmapTracker(config);
651
+ trackerInstance.init();
652
+
653
+ return trackerInstance;
654
+ }
655
+
656
+ export function getHeatmapTracker(): HeatmapTracker | null {
657
+ return trackerInstance;
658
+ }
659
+
660
+ export function stopHeatmapTracking(): void {
661
+ if (trackerInstance) {
662
+ trackerInstance.stop();
663
+ trackerInstance = null;
664
+ }
665
+ }