UNPKG

@dt-workspace/react-native-heatmap

Version:

A modern, highly customizable React Native heatmap component library inspired by GitHub's contribution calendar

764 lines (704 loc) 20.7 kB
"use strict"; /** * Utility functions for heatmap calculations and data processing */ import { COLOR_SCHEMES } from "../types.js"; /** * Generate date range between start and end dates */ export function generateDateRange(startDate, endDate) { const dates = []; const current = new Date(startDate); while (current <= endDate) { dates.push(formatDateISO(current)); current.setDate(current.getDate() + 1); } return dates; } /** * Format date to ISO string (YYYY-MM-DD) */ export function formatDateISO(date) { return date.toISOString().split('T')[0]; } /** * Parse ISO date string to Date object */ export function parseISODate(dateString) { return new Date(dateString + 'T00:00:00.000Z'); } /** * Get week number of the year */ export function getWeekNumber(date) { const firstDayOfYear = new Date(date.getFullYear(), 0, 1); const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000; return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7); } /** * Get day of week (0 = Sunday, 6 = Saturday) */ export function getDayOfWeek(date) { return date.getDay(); } /** * Normalize value to 0-1 range based on min/max values */ export function normalizeValue(value, min, max) { if (max === min) return value > 0 ? 1 : 0; return Math.max(0, Math.min(1, (value - min) / (max - min))); } /** * Calculate color based on normalized value and color scheme */ export function calculateColor(normalizedValue, colorScheme, isEmpty = false) { if (isEmpty || normalizedValue === 0) { return colorScheme.emptyColor || colorScheme.colors[0] || '#f0f0f0'; } const colors = colorScheme.colors; const levels = colorScheme.levels || colors.length; if (normalizedValue >= 1) { return colors[colors.length - 1]; } // Linear interpolation between colors const scaledValue = normalizedValue * (levels - 1); const lowerIndex = Math.floor(scaledValue); const upperIndex = Math.min(lowerIndex + 1, colors.length - 1); const factor = scaledValue - lowerIndex; if (factor === 0 || lowerIndex === upperIndex) { return colors[lowerIndex]; } // Simple color interpolation (you might want to use a more sophisticated color library) return interpolateColor(colors[lowerIndex], colors[upperIndex], factor); } /** * Simple linear interpolation between two hex colors */ function interpolateColor(color1, color2, factor) { const hex1 = color1.replace('#', ''); const hex2 = color2.replace('#', ''); const r1 = parseInt(hex1.substring(0, 2), 16); const g1 = parseInt(hex1.substring(2, 4), 16); const b1 = parseInt(hex1.substring(4, 6), 16); const r2 = parseInt(hex2.substring(0, 2), 16); const g2 = parseInt(hex2.substring(2, 4), 16); const b2 = parseInt(hex2.substring(4, 6), 16); const r = Math.round(r1 + (r2 - r1) * factor); const g = Math.round(g1 + (g2 - g1) * factor); const b = Math.round(b1 + (b2 - b1) * factor); return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; } /** * Resolve color scheme from string or object */ export function resolveColorScheme(colorScheme) { if (typeof colorScheme === 'string') { const predefined = COLOR_SCHEMES[colorScheme]; if (predefined) { return predefined; } // Fallback to github scheme if not found return COLOR_SCHEMES.github; } return colorScheme; } /** * Process raw heatmap data into processed cell data */ export function processHeatmapData(data, startDate, endDate, colorScheme, layout = 'calendar') { const resolvedColorScheme = resolveColorScheme(colorScheme); const dateRange = generateDateRange(startDate, endDate); // Create a map for quick lookup const dataMap = new Map(); data.forEach(item => { dataMap.set(item.date, item); }); // Find min/max values for normalization const values = data.map(item => item.value).filter(value => value !== undefined && value !== null); const minValue = Math.min(...values, 0); const maxValue = Math.max(...values, 0); // Process each date in the range const processedData = dateRange.map((dateString, index) => { const dataPoint = dataMap.get(dateString); const isEmpty = !dataPoint || dataPoint.value === undefined || dataPoint.value === null; const value = isEmpty ? 0 : dataPoint.value; const normalizedValue = normalizeValue(value, minValue, maxValue); const color = calculateColor(normalizedValue, resolvedColorScheme, isEmpty); const date = parseISODate(dateString); let x, y; if (layout === 'calendar') { // Calendar layout: arrange by weeks and days const daysSinceStart = Math.floor((date.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)); const weeksSinceStart = Math.floor(daysSinceStart / 7); x = weeksSinceStart; y = getDayOfWeek(date); } else if (layout === 'grid') { // Grid layout: simple row/column arrangement const columns = Math.ceil(Math.sqrt(dateRange.length)); x = index % columns; y = Math.floor(index / columns); } else if (layout === 'compact') { // Compact layout: single row x = index; y = 0; } else { // Custom layout: fallback to grid const columns = Math.ceil(Math.sqrt(dateRange.length)); x = index % columns; y = Math.floor(index / columns); } return { date: dateString, value, metadata: dataPoint?.metadata, x, y, color, isEmpty, normalizedValue, week: layout === 'calendar' ? getWeekNumber(date) : undefined, dayOfWeek: layout === 'calendar' ? getDayOfWeek(date) : undefined }; }); return processedData; } /** * Calculate calendar layout data with month boundaries */ export function calculateCalendarLayout(processedData, startDate) { // Group data by weeks const weekData = []; const maxWeek = Math.max(...processedData.map(d => d.x)); for (let week = 0; week <= maxWeek; week++) { const weekCells = processedData.filter(d => d.x === week); // Ensure we have 7 days in each week (fill missing days with empty cells) const fullWeek = Array(7).fill(null).map((_, dayIndex) => { const existingCell = weekCells.find(d => d.y === dayIndex); if (existingCell) return existingCell; // Create empty cell for missing days const cellDate = new Date(startDate); cellDate.setDate(cellDate.getDate() + week * 7 + dayIndex); return { date: formatDateISO(cellDate), value: 0, x: week, y: dayIndex, color: '#f0f0f0', isEmpty: true, normalizedValue: 0, week: getWeekNumber(cellDate), dayOfWeek: dayIndex }; }); weekData.push(fullWeek); } // Calculate month boundaries const monthBoundaries = []; let currentMonth = startDate.getMonth(); let monthStart = 0; for (let week = 0; week <= maxWeek; week++) { const weekStart = new Date(startDate); weekStart.setDate(weekStart.getDate() + week * 7); if (weekStart.getMonth() !== currentMonth || week === maxWeek) { if (monthBoundaries.length > 0 || week > 0) { const monthName = new Date(startDate.getFullYear(), currentMonth).toLocaleDateString('en-US', { month: 'short' }); monthBoundaries.push({ month: monthName, x: monthStart, width: week - monthStart }); } currentMonth = weekStart.getMonth(); monthStart = week; } } return { weeks: maxWeek + 1, weekData, monthBoundaries }; } /** * Calculate grid dimensions for layout */ export function calculateGridDimensions(dataLength, preferredColumns, preferredRows) { if (preferredColumns && preferredRows) { return { columns: preferredColumns, rows: preferredRows }; } if (preferredColumns) { return { columns: preferredColumns, rows: Math.ceil(dataLength / preferredColumns) }; } if (preferredRows) { return { columns: Math.ceil(dataLength / preferredRows), rows: preferredRows }; } // Default: try to make it roughly square const columns = Math.ceil(Math.sqrt(dataLength)); const rows = Math.ceil(dataLength / columns); return { columns, rows }; } /** * Calculate heatmap dimensions based on cell size and data */ export function calculateHeatmapDimensions(processedData, cellSize, cellSpacing, layout = 'calendar', gridDimensions) { if (layout === 'calendar') { const maxWeek = Math.max(...processedData.map(d => d.x)); const weeks = maxWeek + 1; const days = 7; // Always 7 days in calendar layout return { width: weeks * (cellSize + cellSpacing) - cellSpacing, height: days * (cellSize + cellSpacing) - cellSpacing }; } if (layout === 'compact') { return { width: processedData.length * (cellSize + cellSpacing) - cellSpacing, height: cellSize }; } if (layout === 'daily') { return { width: 24 * (cellSize + cellSpacing) - cellSpacing, // 24 hours height: cellSize }; } if (layout === 'weekly') { return { width: 7 * (cellSize + cellSpacing) - cellSpacing, // 7 days height: cellSize }; } if (layout === 'monthly') { return { width: 7 * (cellSize + cellSpacing) - cellSpacing, // 7 days per week height: 6 * (cellSize + cellSpacing) - cellSpacing // Max 6 weeks per month }; } if (layout === 'yearly') { return { width: 12 * 8 * (cellSize + cellSpacing) - cellSpacing, // 12 months, ~8 cells per month height: 7 * (cellSize + cellSpacing) - cellSpacing // 7 days per week }; } if (layout === 'customRange') { return { width: processedData.length * (cellSize + cellSpacing) - cellSpacing, height: cellSize }; } if (layout === 'timelineScroll') { // Calculate based on scroll direction const chunkSize = 24; // Default chunk size const chunks = Math.ceil(processedData.length / chunkSize); return { width: chunks * (cellSize + cellSpacing) - cellSpacing, height: chunkSize * (cellSize + cellSpacing) - cellSpacing }; } if (layout === 'realTime') { const windowSize = 24; // Default window size return { width: windowSize * (cellSize + cellSpacing) - cellSpacing, height: cellSize }; } // Grid layout or custom (fallback to grid) const { columns, rows } = gridDimensions || calculateGridDimensions(processedData.length); return { width: columns * (cellSize + cellSpacing) - cellSpacing, height: rows * (cellSize + cellSpacing) - cellSpacing }; } /** * Debounce function for performance optimization */ export function debounce(func, wait) { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; } /** * Throttle function for performance optimization */ export function throttle(func, limit) { let inThrottle; return (...args) => { if (!inThrottle) { func(...args); inThrottle = true; setTimeout(() => inThrottle = false, limit); } }; } /** * =============================== * TIME-BASED LAYOUT UTILITIES * =============================== */ /** * Calculate daily layout (24-hour grid) */ export function calculateDailyLayout(processedData, targetDate, timeFormat = '24h') { const targetDateStr = formatDateISO(targetDate); // Filter data for the target date const dayData = processedData.filter(cell => cell.date === targetDateStr); // Create hour boundaries const timeBoundaries = []; for (let hour = 0; hour < 24; hour++) { const hourStr = timeFormat === '12h' ? formatHour12(hour) : formatHour24(hour); timeBoundaries.push({ hour: hourStr, x: hour, width: 1 }); } // Organize data by hour const hourlyData = new Array(24).fill(null).map((_, hour) => { const hourData = dayData.find(cell => { const cellDate = parseISODate(cell.date); return cellDate.getHours() === hour; }); return hourData || { date: `${targetDateStr}T${hour.toString().padStart(2, '0')}:00:00`, value: 0, x: hour, y: 0, color: '#f0f0f0', isEmpty: true, normalizedValue: 0 }; }); return { hours: 24, hourData: hourlyData, timeBoundaries }; } /** * Calculate weekly layout (7-day activity) */ export function calculateWeeklyLayout(processedData, targetDate) { const startOfWeek = getStartOfWeek(targetDate); const weekDates = []; // Generate 7 days of the week for (let i = 0; i < 7; i++) { const day = new Date(startOfWeek); day.setDate(startOfWeek.getDate() + i); weekDates.push(formatDateISO(day)); } // Create day boundaries const dayBoundaries = weekDates.map((date, index) => ({ day: getDayName(parseISODate(date)), x: index, width: 1 })); // Organize data by day of week const dayData = weekDates.map((date, index) => { const cellData = processedData.find(cell => cell.date === date); return cellData || { date, value: 0, x: index, y: 0, color: '#f0f0f0', isEmpty: true, normalizedValue: 0 }; }); return { days: 7, dayData, dayBoundaries }; } /** * Calculate monthly layout */ export function calculateMonthlyLayout(processedData, targetDate) { const year = targetDate.getFullYear(); const month = targetDate.getMonth(); // Get first day of month and number of days const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const daysInMonth = lastDay.getDate(); const startDayOfWeek = firstDay.getDay(); // Create month grid (weeks x days) const weeks = Math.ceil((daysInMonth + startDayOfWeek) / 7); const monthData = []; for (let week = 0; week < weeks; week++) { const weekData = []; for (let day = 0; day < 7; day++) { const dayNumber = week * 7 + day - startDayOfWeek + 1; if (dayNumber > 0 && dayNumber <= daysInMonth) { const date = new Date(year, month, dayNumber); const dateStr = formatDateISO(date); const cellData = processedData.find(cell => cell.date === dateStr); weekData.push(cellData || { date: dateStr, value: 0, x: day, y: week, color: '#f0f0f0', isEmpty: true, normalizedValue: 0 }); } else { // Empty cell for padding weekData.push({ date: '', value: 0, x: day, y: week, color: 'transparent', isEmpty: true, normalizedValue: 0 }); } } monthData.push(weekData); } // Create week boundaries const weekBoundaries = Array.from({ length: weeks }, (_, index) => ({ week: index + 1, x: 0, width: 7 })); return { daysInMonth, monthData, weekBoundaries }; } /** * Calculate yearly layout */ export function calculateYearlyLayout(processedData, targetDate) { const year = targetDate.getFullYear(); const yearData = []; // Process each month for (let month = 0; month < 12; month++) { const monthDate = new Date(year, month, 1); const monthLayout = calculateMonthlyLayout(processedData, monthDate); yearData.push(monthLayout.monthData.flat()); } // Create month boundaries const monthBoundaries = Array.from({ length: 12 }, (_, index) => { const monthDate = new Date(year, index, 1); return { month: monthDate.toLocaleDateString('en-US', { month: 'short' }), x: index * 8, // Approximate spacing width: 7 }; }); return { months: 12, yearData, monthBoundaries }; } /** * Calculate custom range layout */ export function calculateCustomRangeLayout(processedData, startDate, endDate, granularity = 'day') { const rangeData = []; const periodBoundaries = []; let current = new Date(startDate); let position = 0; while (current <= endDate) { let periodEnd; let periodLabel; switch (granularity) { case 'hour': periodEnd = new Date(current); periodEnd.setHours(current.getHours() + 1); periodLabel = current.toLocaleTimeString('en-US', { hour: 'numeric' }); break; case 'day': periodEnd = new Date(current); periodEnd.setDate(current.getDate() + 1); periodLabel = current.toLocaleDateString('en-US', { day: 'numeric' }); break; case 'week': periodEnd = new Date(current); periodEnd.setDate(current.getDate() + 7); periodLabel = `W${getWeekNumber(current)}`; break; case 'month': periodEnd = new Date(current); periodEnd.setMonth(current.getMonth() + 1); periodLabel = current.toLocaleDateString('en-US', { month: 'short' }); break; } // Find data for this period const periodDataStr = formatDateISO(current); const cellData = processedData.find(cell => cell.date === periodDataStr); rangeData.push(cellData || { date: periodDataStr, value: 0, x: position, y: 0, color: '#f0f0f0', isEmpty: true, normalizedValue: 0 }); periodBoundaries.push({ period: periodLabel, x: position, width: 1 }); current = periodEnd; position++; } return { startDate, endDate, rangeData, periodBoundaries }; } /** * Calculate timeline scroll layout */ export function calculateTimelineScrollLayout(processedData, scrollDirection = 'horizontal', chunkSize = 24) { const timelineData = []; const scrollMarkers = []; // Sort data by date const sortedData = [...processedData].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); // Group data into chunks for (let i = 0; i < sortedData.length; i += chunkSize) { const chunk = sortedData.slice(i, i + chunkSize); timelineData.push(chunk); // Add scroll marker for each chunk if (chunk.length > 0 && chunk[0]) { scrollMarkers.push({ timestamp: chunk[0].date, position: i, label: new Date(chunk[0].date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) }); } } const totalScrollSize = scrollDirection === 'horizontal' ? timelineData.length * chunkSize * 15 // Approximate cell width : timelineData.length * chunkSize * 15; // Approximate cell height return { totalScrollSize, timelineData, scrollMarkers }; } /** * Calculate real-time layout */ export function calculateRealTimeLayout(processedData, windowSize = 24, // Number of time units to show updateInterval = 1000 // Update interval in milliseconds ) { const now = new Date(); const windowStart = new Date(now.getTime() - windowSize * 60 * 60 * 1000); // windowSize hours ago // Filter data for current window const dataBuffer = processedData.filter(cell => { const cellDate = parseISODate(cell.date); return cellDate >= windowStart && cellDate <= now; }); // Create live indicators const liveIndicators = dataBuffer.map((cell, index) => { const cellDate = parseISODate(cell.date); const isRecent = now.getTime() - cellDate.getTime() < updateInterval * 2; return { timestamp: cellDate, position: index, active: isRecent }; }); return { currentWindow: { start: windowStart, end: now }, dataBuffer, updateQueue: [], // Will be populated by real-time updates liveIndicators }; } /** * =============================== * HELPER FUNCTIONS * =============================== */ /** * Format hour in 12-hour format */ function formatHour12(hour) { if (hour === 0) return '12 AM'; if (hour < 12) return `${hour} AM`; if (hour === 12) return '12 PM'; return `${hour - 12} PM`; } /** * Format hour in 24-hour format */ function formatHour24(hour) { return `${hour.toString().padStart(2, '0')}:00`; } /** * Get start of week (Sunday) */ function getStartOfWeek(date) { const start = new Date(date); const day = start.getDay(); const diff = start.getDate() - day; start.setDate(diff); start.setHours(0, 0, 0, 0); return start; } /** * Get day name */ function getDayName(date) { return date.toLocaleDateString('en-US', { weekday: 'short' }); } // Export animation utilities export * from "./animation.js"; // Export gesture utilities export * from "./gestures.js"; //# sourceMappingURL=index.js.map