@dt-workspace/react-native-heatmap
Version:
A modern, highly customizable React Native heatmap component library inspired by GitHub's contribution calendar
396 lines (343 loc) • 9.66 kB
text/typescript
/**
* Virtualization utilities for React Native Heatmap
* Provides efficient rendering for large datasets
*/
import type { ProcessedCellData } from '../types';
/**
* Virtualization configuration
*/
export interface VirtualizationConfig {
/** Enable virtualization */
enabled: boolean;
/** Buffer size around visible area */
bufferSize: number;
/** Minimum number of items to trigger virtualization */
threshold: number;
/** Cell size for calculations */
cellSize: number;
/** Cell spacing for calculations */
cellSpacing: number;
}
/**
* Default virtualization configuration
*/
export const DEFAULT_VIRTUALIZATION_CONFIG: VirtualizationConfig = {
enabled: false,
bufferSize: 50,
threshold: 500,
cellSize: 12,
cellSpacing: 2,
};
/**
* Viewport information for virtualization
*/
export interface Viewport {
/** Viewport width */
width: number;
/** Viewport height */
height: number;
/** Scroll offset X */
offsetX: number;
/** Scroll offset Y */
offsetY: number;
/** Scale factor for zoom */
scale: number;
}
/**
* Visible cell range
*/
export interface VisibleRange {
/** Start index */
startIndex: number;
/** End index */
endIndex: number;
/** Visible cells */
visibleCells: ProcessedCellData[];
}
/**
* Calculate visible cell range based on viewport
*/
export function calculateVisibleRange(
cells: ProcessedCellData[],
viewport: Viewport,
config: VirtualizationConfig
): VisibleRange {
if (!config.enabled || cells.length < config.threshold) {
return {
startIndex: 0,
endIndex: cells.length - 1,
visibleCells: cells,
};
}
const cellWidth = config.cellSize + config.cellSpacing;
const cellHeight = config.cellSize + config.cellSpacing;
// Calculate visible bounds with buffer
const visibleLeft = Math.max(
0,
viewport.offsetX - config.bufferSize * cellWidth
);
const visibleRight =
viewport.offsetX + viewport.width + config.bufferSize * cellWidth;
const visibleTop = Math.max(
0,
viewport.offsetY - config.bufferSize * cellHeight
);
const visibleBottom =
viewport.offsetY + viewport.height + config.bufferSize * cellHeight;
// Filter cells within visible bounds
const visibleCells = cells.filter((cell) => {
const cellLeft = cell.x * cellWidth;
const cellRight = cellLeft + cellWidth;
const cellTop = cell.y * cellHeight;
const cellBottom = cellTop + cellHeight;
return (
cellRight >= visibleLeft &&
cellLeft <= visibleRight &&
cellBottom >= visibleTop &&
cellTop <= visibleBottom
);
});
// Find start and end indices
const startIndex = cells.findIndex((cell) => visibleCells.includes(cell));
const endIndex = startIndex + visibleCells.length - 1;
return {
startIndex: Math.max(0, startIndex),
endIndex: Math.min(cells.length - 1, endIndex),
visibleCells,
};
}
/**
* Calculate optimal virtualization settings based on dataset size
*/
export function calculateOptimalVirtualization(
dataLength: number,
cellSize: number,
cellSpacing: number,
_containerSize: { width: number; height: number }
): VirtualizationConfig {
const baseConfig = { ...DEFAULT_VIRTUALIZATION_CONFIG };
// Enable virtualization for large datasets
if (dataLength > 1000) {
baseConfig.enabled = true;
baseConfig.threshold = 500;
baseConfig.bufferSize = 100;
} else if (dataLength > 500) {
baseConfig.enabled = true;
baseConfig.threshold = 300;
baseConfig.bufferSize = 50;
} else {
baseConfig.enabled = false;
}
baseConfig.cellSize = cellSize;
baseConfig.cellSpacing = cellSpacing;
return baseConfig;
}
/**
* Lazy loading utility for progressive data loading
*/
export class LazyLoader<T> {
private data: T[];
private loadedChunks: Set<number> = new Set();
private chunkSize: number;
private loader: (startIndex: number, endIndex: number) => Promise<T[]>;
constructor(
initialData: T[],
chunkSize: number = 100,
loader?: (startIndex: number, endIndex: number) => Promise<T[]>
) {
this.data = initialData;
this.chunkSize = chunkSize;
this.loader = loader || this.defaultLoader;
}
private defaultLoader = async (
_startIndex: number,
_endIndex: number
): Promise<T[]> => {
// Default implementation returns empty array
return [];
};
/**
* Get data for a specific range, loading if necessary
*/
async getRange(startIndex: number, endIndex: number): Promise<T[]> {
const result: T[] = [];
for (let i = startIndex; i <= endIndex; i++) {
const chunkIndex = Math.floor(i / this.chunkSize);
if (!this.loadedChunks.has(chunkIndex)) {
await this.loadChunk(chunkIndex);
}
const item = this.data[i];
if (item !== null && item !== undefined) {
result.push(item);
}
}
return result;
}
/**
* Load a specific chunk of data
*/
private async loadChunk(chunkIndex: number): Promise<void> {
if (this.loadedChunks.has(chunkIndex)) {
return;
}
const startIndex = chunkIndex * this.chunkSize;
const endIndex = Math.min(
startIndex + this.chunkSize - 1,
this.data.length - 1
);
try {
const chunkData = await this.loader(startIndex, endIndex);
// Insert chunk data into the main array
for (let i = 0; i < chunkData.length; i++) {
const dataIndex = startIndex + i;
const chunkItem = chunkData[i];
if (dataIndex < this.data.length && chunkItem !== undefined) {
this.data[dataIndex] = chunkItem;
}
}
this.loadedChunks.add(chunkIndex);
} catch (error) {
console.warn(`Failed to load chunk ${chunkIndex}:`, error);
}
}
/**
* Preload chunks around a specific range
*/
async preloadAround(centerIndex: number, radius: number = 2): Promise<void> {
const centerChunk = Math.floor(centerIndex / this.chunkSize);
const startChunk = Math.max(0, centerChunk - radius);
const endChunk = Math.min(
Math.floor(this.data.length / this.chunkSize),
centerChunk + radius
);
const loadPromises: Promise<void>[] = [];
for (let chunkIndex = startChunk; chunkIndex <= endChunk; chunkIndex++) {
loadPromises.push(this.loadChunk(chunkIndex));
}
await Promise.all(loadPromises);
}
/**
* Get total data length
*/
get length(): number {
return this.data.length;
}
/**
* Check if a chunk is loaded
*/
isChunkLoaded(chunkIndex: number): boolean {
return this.loadedChunks.has(chunkIndex);
}
/**
* Clear loaded chunks to free memory
*/
clearCache(): void {
this.loadedChunks.clear();
}
}
/**
* Memory optimization utilities
*/
export class MemoryOptimizer {
private static instance: MemoryOptimizer;
private memoryUsage: Map<string, number> = new Map();
private maxMemoryUsage: number = 50 * 1024 * 1024; // 50MB default
static getInstance(): MemoryOptimizer {
if (!MemoryOptimizer.instance) {
MemoryOptimizer.instance = new MemoryOptimizer();
}
return MemoryOptimizer.instance;
}
/**
* Track memory usage for a component
*/
trackMemoryUsage(componentId: string, bytes: number): void {
this.memoryUsage.set(componentId, bytes);
if (this.getTotalMemoryUsage() > this.maxMemoryUsage) {
this.triggerGarbageCollection();
}
}
/**
* Get total memory usage
*/
getTotalMemoryUsage(): number {
return Array.from(this.memoryUsage.values()).reduce(
(sum, usage) => sum + usage,
0
);
}
/**
* Trigger garbage collection for least recently used items
*/
private triggerGarbageCollection(): void {
// Simple LRU implementation - remove oldest entries
const entries = Array.from(this.memoryUsage.entries());
const toRemove = Math.ceil(entries.length * 0.3); // Remove 30% of entries
for (let i = 0; i < toRemove; i++) {
const entry = entries[i];
if (entry) {
this.memoryUsage.delete(entry[0]);
}
}
}
/**
* Clear memory tracking for a component
*/
clearMemoryTracking(componentId: string): void {
this.memoryUsage.delete(componentId);
}
/**
* Set maximum memory usage threshold
*/
setMaxMemoryUsage(bytes: number): void {
this.maxMemoryUsage = bytes;
}
}
/**
* Performance monitoring utilities
*/
export class PerformanceMonitor {
private static measurements: Map<string, number[]> = new Map();
/**
* Start performance measurement
*/
static startMeasurement(key: string): void {
const startTime = performance.now();
const measurements = this.measurements.get(key) || [];
measurements.push(startTime);
this.measurements.set(key, measurements);
}
/**
* End performance measurement and return duration
*/
static endMeasurement(key: string): number {
const measurements = this.measurements.get(key) || [];
if (measurements.length === 0) {
return 0;
}
const startTime = measurements.pop()!;
const duration = performance.now() - startTime;
return duration;
}
/**
* Get average measurement for a key
*/
static getAverageMeasurement(key: string): number {
const measurements = this.measurements.get(key) || [];
if (measurements.length === 0) {
return 0;
}
const sum = measurements.reduce((acc, val) => acc + val, 0);
return sum / measurements.length;
}
/**
* Clear measurements
*/
static clearMeasurements(key?: string): void {
if (key) {
this.measurements.delete(key);
} else {
this.measurements.clear();
}
}
}