@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
422 lines (362 loc) • 10.2 kB
text/typescript
/**
* Heatmap features for visual user behavior analysis
* Dynamically imported to reduce core bundle size
*/
import { TinyTapAnalyticsConfig } from '../types/index';
interface HeatmapPoint {
x: number;
y: number;
intensity: number;
timestamp: number;
type: 'click' | 'move' | 'scroll';
}
interface HeatmapData {
url: string;
viewport: { width: number; height: number };
points: HeatmapPoint[];
sessionId: string;
userId?: string;
}
export class Heatmap {
private config: TinyTapAnalyticsConfig;
private sdk: any;
private clickPoints: HeatmapPoint[] = [];
private movePoints: HeatmapPoint[] = [];
private scrollPoints: HeatmapPoint[] = [];
private isActive = false;
private samplingRate = 0.1; // Sample 10% of users
private maxPoints = 1000;
private debounceTimer?: number;
constructor(config: TinyTapAnalyticsConfig, sdk: any) {
this.config = config;
this.sdk = sdk;
// Use custom sampling rate from config if provided
if (config.heatmapSamplingRate !== undefined) {
this.samplingRate = config.heatmapSamplingRate;
}
}
/**
* Start heatmap tracking
*/
public start(): void {
if (this.isActive || !this.shouldTrackHeatmap()) {
return;
}
this.isActive = true;
this.setupClickTracking();
this.setupMoveTracking();
this.setupScrollTracking();
this.setupPageUnloadTracking();
if (this.config.debug) {
console.log('TinyTapAnalytics: Heatmap tracking started');
}
}
/**
* Stop heatmap tracking
*/
public stop(): void {
if (!this.isActive) {
return;
}
this.isActive = false;
this.sendHeatmapData();
if (this.config.debug) {
console.log('TinyTapAnalytics: Heatmap tracking stopped');
}
}
/**
* Setup click tracking
*/
private setupClickTracking(): void {
document.addEventListener('click', (event) => {
if (!this.isActive) {
return;
}
const point: HeatmapPoint = {
x: event.clientX + window.scrollX,
y: event.clientY + window.scrollY,
intensity: 1,
timestamp: Date.now(),
type: 'click'
};
this.addClickPoint(point);
}, true);
}
/**
* Setup mouse movement tracking
*/
private setupMoveTracking(): void {
let lastMoveTime = 0;
const moveThrottle = 100; // Track every 100ms
document.addEventListener('mousemove', (event) => {
if (!this.isActive) {
return;
}
const now = Date.now();
if (now - lastMoveTime < moveThrottle) {
return;
}
lastMoveTime = now;
const point: HeatmapPoint = {
x: event.clientX + window.scrollX,
y: event.clientY + window.scrollY,
intensity: 0.1,
timestamp: now,
type: 'move'
};
this.addMovePoint(point);
}, { passive: true });
}
/**
* Setup scroll tracking
*/
private setupScrollTracking(): void {
let lastScrollTime = 0;
const scrollThrottle = 250; // Track every 250ms
window.addEventListener('scroll', () => {
if (!this.isActive) {
return;
}
const now = Date.now();
if (now - lastScrollTime < scrollThrottle) {
return;
}
lastScrollTime = now;
const scrollDepth = this.getScrollDepth();
const point: HeatmapPoint = {
x: window.innerWidth / 2, // Center of viewport
y: window.scrollY + (window.innerHeight / 2),
intensity: scrollDepth / 100,
timestamp: now,
type: 'scroll'
};
this.addScrollPoint(point);
}, { passive: true });
}
/**
* Setup page unload tracking
*/
private setupPageUnloadTracking(): void {
window.addEventListener('beforeunload', () => {
this.sendHeatmapData();
});
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.sendHeatmapData();
}
});
}
/**
* Add click point with clustering
*/
private addClickPoint(point: HeatmapPoint): void {
// Check for nearby clicks and cluster them
const nearbyPoint = this.findNearbyPoint(this.clickPoints, point, 20);
if (nearbyPoint) {
nearbyPoint.intensity += 1;
nearbyPoint.timestamp = point.timestamp;
} else {
this.clickPoints.push(point);
}
this.trimPointsArray(this.clickPoints);
this.debouncedSend();
}
/**
* Add move point with sampling
*/
private addMovePoint(point: HeatmapPoint): void {
// Sample mouse moves to reduce data volume
if (Math.random() > 0.1) {
return;
} // Only track 10% of moves
this.movePoints.push(point);
this.trimPointsArray(this.movePoints);
}
/**
* Add scroll point
*/
private addScrollPoint(point: HeatmapPoint): void {
this.scrollPoints.push(point);
this.trimPointsArray(this.scrollPoints);
}
/**
* Find nearby point for clustering
*/
private findNearbyPoint(points: HeatmapPoint[], newPoint: HeatmapPoint, radius: number): HeatmapPoint | null {
return points.find(point => {
const distance = Math.sqrt(
Math.pow(point.x - newPoint.x, 2) + Math.pow(point.y - newPoint.y, 2)
);
return distance <= radius;
}) || null;
}
/**
* Trim points array to prevent memory issues
*/
private trimPointsArray(points: HeatmapPoint[]): void {
if (points.length > this.maxPoints) {
points.splice(0, points.length - this.maxPoints);
}
}
/**
* Debounced send to reduce API calls
*/
private debouncedSend(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = window.setTimeout(() => {
this.sendHeatmapData();
}, 5000); // Send every 5 seconds of activity
}
/**
* Send heatmap data to API
*/
private sendHeatmapData(): void {
if (this.clickPoints.length === 0 && this.movePoints.length === 0 && this.scrollPoints.length === 0) {
return;
}
const heatmapData: HeatmapData = {
url: window.location.href,
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
points: [
...this.clickPoints,
...this.movePoints,
...this.scrollPoints
],
sessionId: this.sdk.sessionId,
userId: this.sdk.userId
};
this.sdk.track('heatmap_data', heatmapData);
// Clear points after sending
this.clickPoints = [];
this.movePoints = [];
this.scrollPoints = [];
if (this.config.debug) {
console.log('TinyTapAnalytics: Heatmap data sent', {
pointCount: heatmapData.points.length,
url: heatmapData.url
});
}
}
/**
* Generate heatmap visualization
*/
public generateHeatmapVisualization(containerId: string): void {
if (!this.isActive) {
console.warn('TinyTapAnalytics: Heatmap tracking is not active');
return;
}
const container = document.getElementById(containerId);
if (!container) {
console.error('TinyTapAnalytics: Heatmap container not found');
return;
}
// Create canvas for heatmap
const canvas = document.createElement('canvas');
canvas.width = window.innerWidth;
canvas.height = document.documentElement.scrollHeight;
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '999999';
const ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
// Draw heatmap points
this.drawHeatmapPoints(ctx, this.clickPoints, 'rgba(255, 0, 0, 0.6)');
this.drawHeatmapPoints(ctx, this.movePoints, 'rgba(0, 255, 0, 0.3)');
this.drawHeatmapPoints(ctx, this.scrollPoints, 'rgba(0, 0, 255, 0.4)');
container.appendChild(canvas);
}
/**
* Draw heatmap points on canvas
*/
private drawHeatmapPoints(ctx: CanvasRenderingContext2D, points: HeatmapPoint[], color: string): void {
points.forEach(point => {
const radius = Math.max(5, point.intensity * 20);
ctx.beginPath();
ctx.arc(point.x, point.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
});
}
/**
* Get scroll depth percentage
*/
private getScrollDepth(): number {
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
return Math.round((scrollTop / (documentHeight - windowHeight)) * 100);
}
/**
* Check if should track heatmap for this user
*/
private shouldTrackHeatmap(): boolean {
// Use sampling to reduce data volume and performance impact
return Math.random() < this.samplingRate;
}
/**
* Get current heatmap statistics
*/
public getStats(): {
isActive: boolean;
clickPoints: number;
movePoints: number;
scrollPoints: number;
totalPoints: number;
} {
return {
isActive: this.isActive,
clickPoints: this.clickPoints.length,
movePoints: this.movePoints.length,
scrollPoints: this.scrollPoints.length,
totalPoints: this.clickPoints.length + this.movePoints.length + this.scrollPoints.length
};
}
/**
* Clear all heatmap data
*/
public clearData(): void {
this.clickPoints = [];
this.movePoints = [];
this.scrollPoints = [];
if (this.config.debug) {
console.log('TinyTapAnalytics: Heatmap data cleared');
}
}
/**
* Set sampling rate
*/
public setSamplingRate(rate: number): void {
this.samplingRate = Math.max(0, Math.min(1, rate));
if (this.config.debug) {
console.log('TinyTapAnalytics: Heatmap sampling rate set to', this.samplingRate);
}
}
/**
* Export heatmap data
*/
public exportData(): HeatmapData {
return {
url: window.location.href,
viewport: {
width: window.innerWidth,
height: window.innerHeight
},
points: [
...this.clickPoints,
...this.movePoints,
...this.scrollPoints
],
sessionId: this.sdk.sessionId,
userId: this.sdk.userId
};
}
}