@tinytapanalytics/sdk
Version:
Behavioral psychology platform that detects visitor frustration, predicts abandonment, and helps you save at-risk conversions in real-time
1,012 lines (855 loc) • 30 kB
text/typescript
/**
* Micro-Interaction Psychology Tracking
* Tracks HOW users interact (not just what), with adaptive performance optimization
*
* Features:
* - Rage click detection
* - Hesitation tracking
* - Form fill analysis
* - Adaptive performance (auto-adjusts based on device capabilities)
* - Client-side significance scoring
* - Smart batching and rate limiting
*/
import { TinyTapAnalyticsConfig } from '../types/index';
export interface MicroInteractionEvent {
id?: string; // Unique ID to prevent duplicates
type: string;
elementSelector: string;
elementType?: string;
pageUrl?: string;
timestamp: number;
hesitationDuration?: number;
isRageClick: boolean;
clickCount?: number;
cursorDistance?: number;
formFillSpeed?: number;
backspaceCount?: number;
scrollSpeed?: number;
isErrorState: boolean;
significanceScore: number;
}
export interface TrackingProfile {
name: string;
significanceThreshold: number;
maxEventsPerMinute: number;
batchSize: number;
batchInterval: number;
}
export class MicroInteractionTracking {
private config: TinyTapAnalyticsConfig;
private sdk: any;
private isActive = false;
// Tracking profiles
private readonly profiles: Record<string, TrackingProfile> = {
minimal: {
name: 'minimal',
significanceThreshold: 0.9,
maxEventsPerMinute: 5,
batchSize: 3,
batchInterval: 10000
},
balanced: {
name: 'balanced',
significanceThreshold: 0.6,
maxEventsPerMinute: 10,
batchSize: 5,
batchInterval: 5000
},
detailed: {
name: 'detailed',
significanceThreshold: 0.3,
maxEventsPerMinute: 20,
batchSize: 10,
batchInterval: 3000
},
performance: {
name: 'performance',
significanceThreshold: 0.5,
maxEventsPerMinute: 8,
batchSize: 4,
batchInterval: 7000
}
};
private currentProfile: TrackingProfile;
// Event queue and batching
private eventQueue: MicroInteractionEvent[] = [];
private batchTimer?: number;
// Interaction state
private interactionState: {
lastClick?: { element: string; time: number; count: number };
hoverStart?: number;
lastMousePos?: { x: number; y: number };
cursorDistance?: number;
inputStart?: { time: number; element: string; initialValue: string };
backspaceCount?: number;
lastScroll?: { position: number; time: number };
} = {};
// Rate limiting
private eventTimestamps: number[] = [];
// Statistics tracking
private stats = {
eventCount: 0,
filteredEventCount: 0,
rateLimitedCount: 0,
batchCount: 0
};
// Performance monitoring
private performanceMonitor?: PerformanceMonitor;
private adaptiveOptimizer?: AdaptivePerformanceOptimizer;
// Event listeners cleanup
private listeners: Map<string, { target: EventTarget; event: string; handler: EventListener; options?: AddEventListenerOptions }> = new Map();
private listenerIdCounter = 0;
constructor(config: TinyTapAnalyticsConfig, sdk: any) {
this.config = config;
this.sdk = sdk;
// Select profile
const profileName = (config as any).microInteractionProfile || 'balanced';
this.currentProfile = this.profiles[profileName] || this.profiles.balanced;
// Initialize performance monitoring if adaptive is enabled
if ((config as any).adaptiveSampling !== false) {
this.performanceMonitor = new PerformanceMonitor();
this.adaptiveOptimizer = new AdaptivePerformanceOptimizer(this.performanceMonitor);
}
}
/**
* Start micro-interaction tracking
*/
public start(): void {
if (this.isActive) {
return;
}
this.isActive = true;
// Attach event listeners
this.attachClickTracking();
this.attachDeadClickTracking();
this.attachInputTracking();
this.attachMouseTracking();
this.attachScrollTracking();
// Start batch timer
this.startBatchTimer();
// Start adaptive optimization if enabled
if (this.adaptiveOptimizer) {
this.adaptiveOptimizer.start(() => {
this.applyAdaptiveSettings();
});
}
if (this.config.debug) {
console.log(`[TinyTapAnalytics] Micro-interaction tracking started with profile: ${this.currentProfile.name}`);
}
}
/**
* Stop micro-interaction tracking
*/
public stop(): void {
if (!this.isActive) {
return;
}
this.isActive = false;
// Clear batch timer
if (this.batchTimer) {
clearTimeout(this.batchTimer);
}
// Stop adaptive optimizer
if (this.adaptiveOptimizer) {
this.adaptiveOptimizer.stop();
}
// Clean up event listeners
this.listeners.forEach(({ target, event, handler, options }) => {
target.removeEventListener(event, handler, options);
});
this.listeners.clear();
// Flush remaining events
this.flush();
if (this.config.debug) {
console.log('[TinyTapAnalytics] Micro-interaction tracking stopped');
}
}
/**
* Apply adaptive performance settings
*/
private applyAdaptiveSettings(): void {
if (!this.adaptiveOptimizer) {
return;
}
const recommendation = this.adaptiveOptimizer.getRecommendation();
if (recommendation.shouldThrottle) {
// Increase threshold (less events), increase batch interval
this.currentProfile.significanceThreshold = Math.min(0.95, this.currentProfile.significanceThreshold + 0.1);
this.currentProfile.batchInterval = Math.min(15000, this.currentProfile.batchInterval + 2000);
if (this.config.debug) {
console.log(`[TinyTapAnalytics] Throttling: ${recommendation.reason}`);
}
} else if (recommendation.canIncrease) {
// Decrease threshold (more events), decrease batch interval
this.currentProfile.significanceThreshold = Math.max(0.3, this.currentProfile.significanceThreshold - 0.05);
this.currentProfile.batchInterval = Math.max(3000, this.currentProfile.batchInterval - 1000);
if (this.config.debug) {
console.log(`[TinyTapAnalytics] Increasing fidelity: ${recommendation.reason}`);
}
}
}
/**
* Attach click tracking with rage click detection
*/
private attachClickTracking(): void {
const clickHandler = (event: Event) => {
const target = event.target as Element;
const selector = this.getElementSelector(target);
const now = Date.now();
// Detect rage clicks
const lastClick = this.interactionState.lastClick;
const isRageClick = lastClick?.element === selector &&
(now - lastClick.time) < 500 &&
lastClick.count >= 2;
const clickCount = lastClick?.element === selector ? lastClick.count + 1 : 1;
// Calculate hesitation
const hesitationDuration = this.interactionState.hoverStart ?
now - this.interactionState.hoverStart : undefined;
// Track the event
this.trackEvent({
type: 'click',
elementSelector: selector,
elementType: target.tagName.toLowerCase(),
pageUrl: window.location.href,
timestamp: now,
hesitationDuration,
isRageClick,
clickCount,
cursorDistance: this.interactionState.cursorDistance,
isErrorState: this.isElementInErrorState(target),
significanceScore: 0 // Will be calculated
});
// Update state
this.interactionState.lastClick = { element: selector, time: now, count: clickCount };
};
this.addEventListener(document, 'click', clickHandler, true);
}
/**
* Attach dead click detection (clicks on non-interactive elements that look clickable)
*/
private attachDeadClickTracking(): void {
const deadClickHandler = (event: Event) => {
const target = event.target as Element;
// Skip if already tracked as interactive
if (this.isInteractiveElement(target)) {
return;
}
// Check if element LOOKS clickable but isn't
const looksClickable =
window.getComputedStyle(target).cursor === 'pointer' ||
(target.closest('[style*="cursor: pointer"]') !== null) ||
(target.tagName.match(/^(DIV|SPAN|P|IMG)$/) &&
target.textContent?.match(/^(Click|View|See|Learn|Read|More|Details|Info|Buy|Shop|Get)/i));
if (looksClickable) {
this.trackEvent({
type: 'dead_click',
elementSelector: this.getElementSelector(target),
elementType: target.tagName.toLowerCase(),
pageUrl: window.location.href,
timestamp: Date.now(),
isRageClick: false,
isErrorState: false,
significanceScore: 0.8 // Dead clicks are highly significant - indicate UI confusion
});
if (this.config.debug) {
console.log('[TinyTapAnalytics] Dead click detected on:', this.getElementSelector(target));
}
}
};
this.addEventListener(document, 'click', deadClickHandler, true);
}
/**
* Attach input field tracking
*/
private attachInputTracking(): void {
const focusHandler = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!this.isFormElement(target)) {
return;
}
// Skip password fields for privacy
if (target.type === 'password') {
return;
}
const selector = this.getElementSelector(target);
this.interactionState.inputStart = {
time: Date.now(),
element: selector,
initialValue: target.value || ''
};
this.interactionState.backspaceCount = 0;
};
const blurHandler = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!this.isFormElement(target)) {
return;
}
// Skip password fields for privacy
if (target.type === 'password') {
return;
}
const selector = this.getElementSelector(target);
const inputStart = this.interactionState.inputStart;
if (!inputStart || inputStart.element !== selector) {
return;
}
const now = Date.now();
const duration = now - inputStart.time;
const finalValue = target.value || '';
const charactersTyped = Math.abs(finalValue.length - inputStart.initialValue.length);
const formFillSpeed = duration > 0 ? (charactersTyped / duration) * 1000 : 0;
// Calculate significance score for input events
let inputSignificance = 0.3; // Base significance for input tracking
// High backspace count indicates corrections/frustration
if (this.interactionState.backspaceCount && this.interactionState.backspaceCount > 2) {
inputSignificance += 0.3;
}
// Error state is highly significant
if (this.isElementInErrorState(target)) {
inputSignificance += 0.3;
}
// Very slow typing (< 1 char/sec) or very fast (> 10 char/sec) might indicate copy-paste or struggle
if (formFillSpeed > 0 && (formFillSpeed < 1 || formFillSpeed > 10)) {
inputSignificance += 0.2;
}
this.trackEvent({
type: 'input_blur',
elementSelector: selector,
elementType: target.tagName.toLowerCase(),
pageUrl: window.location.href,
timestamp: now,
formFillSpeed,
backspaceCount: this.interactionState.backspaceCount,
isRageClick: false,
isErrorState: this.isElementInErrorState(target),
significanceScore: Math.min(inputSignificance, 1.0)
});
this.interactionState.inputStart = undefined;
this.interactionState.backspaceCount = 0;
};
const inputHandler = (event: Event) => {
const target = event.target as HTMLInputElement;
// Skip password fields for privacy
if (target && target.type === 'password') {
return;
}
const inputEvent = event as InputEvent;
if (inputEvent.inputType === 'deleteContentBackward') {
this.interactionState.backspaceCount = (this.interactionState.backspaceCount || 0) + 1;
}
};
this.addEventListener(document, 'focus', focusHandler, true);
this.addEventListener(document, 'blur', blurHandler, true);
this.addEventListener(document, 'input', inputHandler, true);
}
/**
* Attach mouse movement tracking (RAF-throttled for better performance)
*/
private attachMouseTracking(): void {
let rafId: number | null = null;
const lastPosition = { x: 0, y: 0 };
const processMouseMove = (x: number, y: number) => {
// Update cursor distance
if (this.interactionState.lastMousePos) {
const distance = Math.sqrt(
Math.pow(x - this.interactionState.lastMousePos.x, 2) +
Math.pow(y - this.interactionState.lastMousePos.y, 2)
);
this.interactionState.cursorDistance = (this.interactionState.cursorDistance || 0) + distance;
}
this.interactionState.lastMousePos = { x, y };
// Track hover start for hesitation calculation
const element = document.elementFromPoint(x, y);
if (element && this.isInteractiveElement(element)) {
if (!this.interactionState.hoverStart) {
this.interactionState.hoverStart = Date.now();
}
} else {
this.interactionState.hoverStart = undefined;
}
rafId = null;
};
const mouseMoveHandler = (event: Event) => {
const mouseEvent = event as MouseEvent;
lastPosition.x = mouseEvent.clientX;
lastPosition.y = mouseEvent.clientY;
// Only schedule processing if not already scheduled
if (rafId === null) {
rafId = requestAnimationFrame(() => {
processMouseMove(lastPosition.x, lastPosition.y);
});
}
};
this.addEventListener(document, 'mousemove', mouseMoveHandler, { passive: true });
}
/**
* Attach scroll tracking (throttled)
*/
private attachScrollTracking(): void {
let lastScroll = 0;
const scrollHandler = () => {
const now = Date.now();
if (now - lastScroll < 200) {
return;
} // Throttle to 5Hz
lastScroll = now;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
if (this.interactionState.lastScroll) {
const distance = Math.abs(scrollTop - this.interactionState.lastScroll.position);
const time = now - this.interactionState.lastScroll.time;
const scrollSpeed = time > 0 ? (distance / time) * 1000 : 0;
// Only track significant scrolls
if (distance > 50) {
this.trackEvent({
type: 'scroll',
elementSelector: 'window',
elementType: 'window',
pageUrl: window.location.href,
timestamp: now,
scrollSpeed,
isRageClick: false,
isErrorState: false,
significanceScore: 0
});
}
}
this.interactionState.lastScroll = { position: scrollTop, time: now };
};
this.addEventListener(window, 'scroll', scrollHandler, { passive: true });
}
/**
* Track a micro-interaction event
*/
private trackEvent(event: MicroInteractionEvent): void {
// Generate unique ID for this event to prevent duplicates
event.id = this.generateEventId(event);
// Calculate significance score
event.significanceScore = this.calculateSignificance(event);
if (this.config.debug) {
console.log('[TinyTapAnalytics] Event:', event.type, 'Significance:', event.significanceScore.toFixed(2));
}
// Filter by significance threshold
if (event.significanceScore < this.currentProfile.significanceThreshold) {
this.stats.filteredEventCount++;
if (this.config.debug) {
console.log('[TinyTapAnalytics] Event filtered (low significance)');
}
return;
}
// Apply rate limiting
if (!this.allowEvent()) {
this.stats.rateLimitedCount++;
if (this.config.debug) {
console.log('[TinyTapAnalytics] Event dropped (rate limit)');
}
return;
}
// Add to queue
this.eventQueue.push(event);
this.stats.eventCount++;
// Send batch if queue is full
if (this.eventQueue.length >= this.currentProfile.batchSize) {
this.sendBatch();
}
}
/**
* Generate unique ID for micro-interaction event
* Format: {sessionId}_{timestamp}_{type}_{hash}
*/
private generateEventId(event: MicroInteractionEvent): string {
const sessionId = this.sdk.getSessionId();
const hash = this.simpleHash(`${event.type}_${event.elementSelector}_${event.timestamp}`);
return `${sessionId}_${event.timestamp}_${hash}`;
}
/**
* Simple hash function for event ID generation
*/
private simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash).toString(36);
}
/**
* Calculate significance score for an event
*/
private calculateSignificance(event: MicroInteractionEvent): number {
let score = 0.5; // Base score
// Dead clicks are highly significant - indicate UI confusion
if (event.type === 'dead_click') {
return 0.8;
}
// Input events have custom significance calculation
if (event.type === 'input_blur') {
// Use the pre-calculated score from the input tracking handler
return event.significanceScore;
}
// High significance events
if (event.isRageClick) {
score += 0.5;
}
if (event.isErrorState) {
score += 0.3;
}
if (event.type === 'form_submit') {
score += 0.4;
}
// Hesitation indicates uncertainty/confusion
if (event.hesitationDuration) {
if (event.hesitationDuration > 2000) {
score += 0.3;
} else if (event.hesitationDuration > 1000) {
score += 0.2;
}
}
// Multiple clicks on same element
if (event.clickCount && event.clickCount > 2) {
score += 0.2;
}
// Slow form filling indicates difficulty
if (event.formFillSpeed !== undefined && event.formFillSpeed < 1.0 && event.formFillSpeed > 0) {
score += 0.2;
}
// Many corrections indicate confusion
if (event.backspaceCount && event.backspaceCount > 3) {
score += 0.2;
}
// Very fast scrolling might indicate frustration
if (event.scrollSpeed && event.scrollSpeed > 2000) {
score += 0.15;
}
return Math.min(1.0, score);
}
/**
* Rate limiting check
*/
private allowEvent(): boolean {
const now = Date.now();
const oneMinuteAgo = now - 60000;
// Remove old timestamps
this.eventTimestamps = this.eventTimestamps.filter(ts => ts > oneMinuteAgo);
if (this.eventTimestamps.length >= this.currentProfile.maxEventsPerMinute) {
return false;
}
this.eventTimestamps.push(now);
return true;
}
/**
* Start batch timer with Page Visibility API support
*/
private startBatchTimer(): void {
const scheduleBatch = () => {
if (this.eventQueue.length > 0) {
this.sendBatch();
}
// Reschedule if page is visible and tracking is active
if (!document.hidden && this.isActive) {
this.batchTimer = window.setTimeout(scheduleBatch, this.currentProfile.batchInterval);
}
};
// Start initial timer
this.batchTimer = window.setTimeout(scheduleBatch, this.currentProfile.batchInterval);
// Handle visibility changes
const visibilityHandler = () => {
if (document.hidden) {
// Flush when hiding
if (this.eventQueue.length > 0) {
this.sendBatch();
}
// Clear timer
if (this.batchTimer) {
clearTimeout(this.batchTimer);
this.batchTimer = undefined;
}
} else if (this.isActive) {
// Resume when visible
scheduleBatch();
}
};
this.addEventListener(document, 'visibilitychange', visibilityHandler);
}
/**
* Send batch of events to server
*/
private async sendBatch(): Promise<void> {
if (this.eventQueue.length === 0) {
return;
}
const batch = this.eventQueue.splice(0, this.currentProfile.batchSize);
try {
// Send directly to micro-interactions endpoint (not analytics track endpoint)
const apiUrl = this.config.endpoint || 'https://api.tinytapanalytics.com';
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (this.config.apiKey) {
headers['X-API-Key'] = this.config.apiKey;
}
const response = await fetch(`${apiUrl}/api/v1/micro-interactions`, {
method: 'POST',
headers,
body: JSON.stringify({
events: batch,
sessionId: this.sdk.getSessionId(),
websiteId: this.config.websiteId,
timestamp: Date.now()
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
this.stats.batchCount++;
if (this.config.debug) {
console.log(`[TinyTapAnalytics] Micro-interaction batch sent successfully: ${batch.length} events`);
}
} catch (error) {
console.error('[TinyTapAnalytics] Error sending micro-interaction batch:', error);
// Re-queue events on failure (with limit)
if (this.eventQueue.length < 50) {
this.eventQueue.unshift(...batch);
}
}
}
/**
* Flush remaining events (called on page unload)
*/
public flush(): void {
if (this.eventQueue.length > 0) {
// Use sendBeacon for reliable delivery during page unload
const batch = this.eventQueue.splice(0);
const apiUrl = this.config.endpoint || 'https://api.tinytapanalytics.com';
// sendBeacon can't send custom headers, so include API key and websiteId in URL params
const params = new URLSearchParams();
if (this.config.apiKey) {
params.append('apiKey', this.config.apiKey);
}
if (this.config.websiteId) {
params.append('websiteId', this.config.websiteId);
}
const data = JSON.stringify({
events: batch,
sessionId: this.sdk.getSessionId(),
websiteId: this.config.websiteId,
timestamp: Date.now()
});
const url = `${apiUrl}/api/v1/micro-interactions${params.toString() ? '?' + params.toString() : ''}`;
const blob = new Blob([data], { type: 'application/json' });
navigator.sendBeacon(url, blob);
}
}
// Helper methods
/**
* Add event listener with proper cleanup tracking
*/
private addEventListener(target: EventTarget, event: string, handler: EventListener, options?: boolean | AddEventListenerOptions): string {
const key = `${event}-${this.listenerIdCounter++}`;
target.addEventListener(event, handler, options);
const normalizedOptions = typeof options === 'boolean' ? { capture: options } : options;
this.listeners.set(key, { target, event, handler, options: normalizedOptions });
return key;
}
private getElementSelector(element: Element): string {
// Prefer data-tracking attributes (convention for analytics)
const trackingId = element.getAttribute('data-track-id') ||
element.getAttribute('data-testid') ||
element.getAttribute('data-cy');
if (trackingId) {
return `[data-track-id="${trackingId}"]`;
}
// Then ID
if (element.id) {
return `#${element.id}`;
}
// Build a unique path (max 4 levels)
const path: string[] = [];
let current: Element | null = element;
while (current && current !== document.body && path.length < 4) {
let selector = current.tagName.toLowerCase();
// Add meaningful classes (filter out utility classes)
if (current.className && typeof current.className === 'string') {
const meaningfulClasses = current.className
.trim()
.split(/\s+/)
.filter(c => c && !c.match(/^(d-|m-|p-|text-|bg-|flex-|grid-|w-|h-|border-|rounded-|shadow-|opacity-)/))
.slice(0, 2)
.join('.');
if (meaningfulClasses) {
selector += `.${meaningfulClasses}`;
}
}
// Add nth-child if no meaningful identifier
if (current.parentElement && selector === current.tagName.toLowerCase()) {
const siblings = Array.from(current.parentElement.children);
const index = siblings.indexOf(current) + 1;
selector += `:nth-child(${index})`;
}
path.unshift(selector);
current = current.parentElement;
}
return path.join(' > ') || element.tagName.toLowerCase();
}
private isFormElement(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
return tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
private isInteractiveElement(element: Element): boolean {
const tagName = element.tagName.toLowerCase();
const interactiveTags = ['button', 'a', 'input', 'select', 'textarea'];
return interactiveTags.includes(tagName) ||
(element as HTMLElement).onclick !== null;
}
private isElementInErrorState(element: Element): boolean {
return element.classList.contains('error') ||
element.classList.contains('invalid') ||
element.getAttribute('aria-invalid') === 'true' ||
!!element.parentElement?.querySelector('.error-message');
}
/**
* Update the tracking profile dynamically
*/
public setProfile(profileName: 'minimal' | 'balanced' | 'detailed' | 'performance'): void {
const newProfile = this.profiles[profileName];
if (!newProfile) {
console.warn(`[TinyTapAnalytics] Invalid profile name: ${profileName}. Using current profile.`);
return;
}
const oldProfile = this.currentProfile.name;
this.currentProfile = newProfile;
if (this.config.debug) {
console.log(`[TinyTapAnalytics] Micro-interaction profile changed: ${oldProfile} → ${profileName}`);
}
// If batch timer needs adjustment, restart it
if (this.isActive) {
if (this.batchTimer) {
clearTimeout(this.batchTimer);
}
this.startBatchTimer();
}
}
/**
* Get current profile name
*/
public getProfile(): string {
return this.currentProfile.name;
}
/**
* Get current tracking statistics
*/
public getStats(): {
isActive: boolean;
profile: string;
currentProfile: string;
queueSize: number;
eventsThisMinute: number;
significanceThreshold: number;
maxEventsPerMinute: number;
batchSize: number;
eventCount: number;
filteredEventCount: number;
rateLimitedCount: number;
batchCount: number;
} {
const now = Date.now();
const oneMinuteAgo = now - 60000;
const recentEvents = this.eventTimestamps.filter(ts => ts > oneMinuteAgo).length;
return {
isActive: this.isActive,
profile: this.currentProfile.name,
currentProfile: this.currentProfile.name,
queueSize: this.eventQueue.length,
eventsThisMinute: recentEvents,
significanceThreshold: this.currentProfile.significanceThreshold,
maxEventsPerMinute: this.currentProfile.maxEventsPerMinute,
batchSize: this.currentProfile.batchSize,
eventCount: this.stats.eventCount,
filteredEventCount: this.stats.filteredEventCount,
rateLimitedCount: this.stats.rateLimitedCount,
batchCount: this.stats.batchCount
};
}
}
/**
* Performance Monitor - Tracks device capabilities
*/
class PerformanceMonitor {
public getMetrics(): {
memory?: { usagePercent: number };
battery?: { level: number; charging: boolean };
connection?: { effectiveType: string; saveData?: boolean };
} {
const metrics: any = {};
// Memory
if ((performance as any).memory) {
const memory = (performance as any).memory;
metrics.memory = {
usagePercent: (memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100
};
}
// Connection
if ((navigator as any).connection) {
const conn = (navigator as any).connection;
metrics.connection = {
effectiveType: conn.effectiveType,
saveData: conn.saveData
};
}
return metrics;
}
}
/**
* Adaptive Performance Optimizer
*/
class AdaptivePerformanceOptimizer {
private monitor: PerformanceMonitor;
private monitorInterval?: number;
constructor(monitor: PerformanceMonitor) {
this.monitor = monitor;
}
public start(callback: () => void): void {
this.monitorInterval = window.setInterval(() => {
callback();
}, 30000); // Check every 30 seconds
}
public stop(): void {
if (this.monitorInterval) {
clearInterval(this.monitorInterval);
}
}
public getRecommendation(): {
shouldThrottle: boolean;
canIncrease: boolean;
reason: string | null;
} {
const metrics = this.monitor.getMetrics();
const recommendation = {
shouldThrottle: false,
canIncrease: false,
reason: null as string | null
};
// Check memory pressure
if (metrics.memory && metrics.memory.usagePercent > 90) {
recommendation.shouldThrottle = true;
recommendation.reason = 'high_memory_usage';
return recommendation;
}
// Check network
if (metrics.connection) {
if (metrics.connection.saveData ||
metrics.connection.effectiveType === 'slow-2g' ||
metrics.connection.effectiveType === '2g') {
recommendation.shouldThrottle = true;
recommendation.reason = 'poor_network';
return recommendation;
}
if (metrics.connection.effectiveType === '4g' &&
(!metrics.memory || metrics.memory.usagePercent < 50)) {
recommendation.canIncrease = true;
recommendation.reason = 'good_conditions';
}
}
return recommendation;
}
}