UNPKG

@dt-workspace/react-native-heatmap

Version:

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

572 lines (534 loc) 19.7 kB
"use strict"; /** * Main Heatmap component */ import React, { useMemo, useCallback, useState, useRef } from 'react'; import { View, StyleSheet, Dimensions } from 'react-native'; import Svg, { Rect, Text as SvgText, G } from 'react-native-svg'; import { DEFAULT_THEME } from "../types.js"; import { processHeatmapData, calculateHeatmapDimensions, calculateCalendarLayout, calculateDailyLayout, calculateWeeklyLayout, calculateMonthlyLayout, calculateYearlyLayout, calculateCustomRangeLayout, calculateTimelineScrollLayout, calculateRealTimeLayout, DEFAULT_ANIMATION_CONFIG, mergeAnimationConfig, DEFAULT_GESTURE_CONFIG, mergeGestureConfig, isAnimationSupported, isGestureHandlerAvailable } from "../utils/index.js"; import Tooltip from "./Tooltip.js"; import AnimatedCell from "./AnimatedCell.js"; /** * Default props for the Heatmap component */ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const defaultProps = { cellSize: 12, cellSpacing: 2, layout: 'calendar', colorScheme: 'github', animated: true, showTooltip: false, showMonthLabels: true, showWeekdayLabels: true, showLegend: false, hapticFeedback: false, accessibility: { label: 'Heatmap visualization', role: 'grid' } }; /** * Main Heatmap Component */ const Heatmap = props => { const { data, width, height, cellSize = defaultProps.cellSize, cellSpacing = defaultProps.cellSpacing, cellShape = 'square', layout = defaultProps.layout, colorScheme = defaultProps.colorScheme, theme = {}, startDate, endDate, numDays, onCellPress, onCellLongPress, onCellPressIn, onCellPressOut, onCellDoublePress, tooltip, showTooltip = defaultProps.showTooltip, tooltipContent, animated = defaultProps.animated, animation, animationDuration = 300, gesture, panEnabled, zoomEnabled, hapticFeedback = defaultProps.hapticFeedback, accessibility = defaultProps.accessibility, showMonthLabels = defaultProps.showMonthLabels, showWeekdayLabels = defaultProps.showWeekdayLabels, // showLegend = defaultProps.showLegend!, // legendPosition = 'bottom', columns, rows, // New time-based layout props targetDate, hourFormat = '24h', showTimeLabels = false, scrollDirection = 'horizontal', updateInterval = 1000, customRange, style, cellStyle, labelStyle } = props; // State for tooltip const [tooltipData, setTooltipData] = useState(null); // Container dimensions const [containerDimensions, setContainerDimensions] = useState({ width: Dimensions.get('window').width, height: Dimensions.get('window').height }); // Refs for gesture handling const gestureRef = useRef(null); // Merge theme with defaults const mergedTheme = useMemo(() => ({ colors: { ...DEFAULT_THEME.colors, ...theme.colors }, spacing: { ...DEFAULT_THEME.spacing, ...theme.spacing }, typography: { ...DEFAULT_THEME.typography, ...theme.typography } }), [theme]); // Merge animation configuration const mergedAnimationConfig = useMemo(() => { const baseConfig = { ...DEFAULT_ANIMATION_CONFIG, enabled: animated && isAnimationSupported(), duration: animationDuration }; return mergeAnimationConfig(baseConfig, animation); }, [animated, animationDuration, animation]); // Merge gesture configuration const mergedGestureConfig = useMemo(() => { const baseConfig = { ...DEFAULT_GESTURE_CONFIG, enabled: isGestureHandlerAvailable(), pan: panEnabled ?? DEFAULT_GESTURE_CONFIG.pan, zoom: zoomEnabled ?? DEFAULT_GESTURE_CONFIG.zoom, hapticFeedback }; return mergeGestureConfig(baseConfig, gesture); }, [panEnabled, zoomEnabled, hapticFeedback, gesture]); // Merge tooltip configuration const mergedTooltipConfig = useMemo(() => { const baseConfig = { enabled: showTooltip, content: tooltipContent, position: 'auto', offset: 8, showArrow: true, backgroundColor: mergedTheme.colors.tooltip, textColor: mergedTheme.colors.tooltipText, fontSize: mergedTheme.typography.fontSize, padding: 8, borderRadius: 4, shadow: true }; return { ...baseConfig, ...tooltip }; }, [showTooltip, tooltipContent, tooltip, mergedTheme]); // Calculate date range const dateRange = useMemo(() => { const now = new Date(); const defaultStart = new Date(now.getFullYear(), 0, 1); // Start of current year const defaultEnd = new Date(now.getFullYear(), 11, 31); // End of current year let calculatedStart = startDate || defaultStart; let calculatedEnd = endDate || defaultEnd; if (numDays && !endDate) { calculatedEnd = new Date(calculatedStart); calculatedEnd.setDate(calculatedEnd.getDate() + numDays - 1); } return { start: calculatedStart, end: calculatedEnd }; }, [startDate, endDate, numDays]); // Process heatmap data const processedData = useMemo(() => { return processHeatmapData(data, dateRange.start, dateRange.end, colorScheme, layout === 'daily' || layout === 'weekly' || layout === 'monthly' || layout === 'yearly' || layout === 'customRange' || layout === 'timelineScroll' || layout === 'realTime' ? 'grid' : layout); }, [data, dateRange.start, dateRange.end, colorScheme, layout]); // Calculate calendar layout data const calendarLayout = useMemo(() => { if (layout === 'calendar') { return calculateCalendarLayout(processedData, dateRange.start); } return null; }, [processedData, dateRange.start, layout]); // Calculate time-based layouts const timeBasedLayouts = useMemo(() => { const currentDate = targetDate || new Date(); switch (layout) { case 'daily': return { daily: calculateDailyLayout(processedData, currentDate, hourFormat) }; case 'weekly': return { weekly: calculateWeeklyLayout(processedData, currentDate) }; case 'monthly': return { monthly: calculateMonthlyLayout(processedData, currentDate) }; case 'yearly': return { yearly: calculateYearlyLayout(processedData, currentDate) }; case 'customRange': if (customRange) { return { customRange: calculateCustomRangeLayout(processedData, customRange.start, customRange.end, customRange.granularity) }; } return null; case 'timelineScroll': return { timelineScroll: calculateTimelineScrollLayout(processedData, scrollDirection, 24 // Default chunk size ) }; case 'realTime': return { realTime: calculateRealTimeLayout(processedData, 24, updateInterval) }; default: return null; } }, [layout, processedData, targetDate, hourFormat, customRange, scrollDirection, updateInterval]); // Calculate dimensions const dimensions = useMemo(() => { const gridDims = columns && rows ? { columns, rows } : undefined; return calculateHeatmapDimensions(processedData, cellSize, cellSpacing, layout, gridDims); }, [processedData, cellSize, cellSpacing, layout, columns, rows]); // Calculate final dimensions const finalWidth = width || dimensions.width; const finalHeight = height || dimensions.height; // Resolve color scheme (not used directly in component but available for future features) // const resolvedColorScheme = useMemo(() => { // return resolveColorScheme(colorScheme); // }, [colorScheme]); // Handle cell press const handleCellPress = useCallback((cellData, index) => { onCellPress?.(cellData, index); // Hide tooltip on press if (tooltipData?.visible) { setTooltipData(null); } }, [onCellPress, tooltipData]); // Handle cell long press const handleCellLongPress = useCallback((cellData, index) => { onCellLongPress?.(cellData, index); // Show tooltip on long press if enabled if (mergedTooltipConfig.enabled) { const x = cellData.x * (cellSize + cellSpacing); const y = cellData.y * (cellSize + cellSpacing); setTooltipData({ data: cellData, position: { x, y }, visible: true }); } }, [onCellLongPress, mergedTooltipConfig.enabled, cellSize, cellSpacing]); // Handle cell press in const handleCellPressIn = useCallback((cellData, index) => { onCellPressIn?.(cellData, index); }, [onCellPressIn]); // Handle cell press out const handleCellPressOut = useCallback((cellData, index) => { onCellPressOut?.(cellData, index); }, [onCellPressOut]); // Handle cell double press const handleCellDoublePress = useCallback((cellData, index) => { onCellDoublePress?.(cellData, index); }, [onCellDoublePress]); // Handle container layout const handleContainerLayout = useCallback(event => { const { width: layoutWidth, height: layoutHeight } = event.nativeEvent.layout; setContainerDimensions({ width: layoutWidth, height: layoutHeight }); }, []); // Render cell based on animation support const renderCell = useCallback((cellData, index) => { // Use AnimatedCell if animations are enabled and supported if (mergedAnimationConfig.enabled && isAnimationSupported()) { return /*#__PURE__*/_jsx(AnimatedCell, { data: cellData, index: index, totalCells: processedData.length, cellSize: cellSize, cellSpacing: cellSpacing, cellShape: cellShape, animationConfig: mergedAnimationConfig, borderColor: mergedTheme.colors.border, borderWidth: 0.5, cellStyle: cellStyle, onPress: handleCellPress, onLongPress: handleCellLongPress, onPressIn: handleCellPressIn, onPressOut: handleCellPressOut, onDoublePress: handleCellDoublePress, hapticFeedback: mergedGestureConfig.hapticFeedback, useSvg: true }, `cell-${index}`); } // Fallback to SVG rendering const x = cellData.x * (cellSize + cellSpacing); const y = cellData.y * (cellSize + cellSpacing); const cellProps = { x, y, width: cellSize, height: cellSize, fill: cellData.color, stroke: mergedTheme.colors.border, strokeWidth: 0.5, onPress: () => handleCellPress(cellData, index), onLongPress: () => handleCellLongPress(cellData, index), ...cellStyle }; if (cellShape === 'circle') { return /*#__PURE__*/_jsx(Rect, { ...cellProps, rx: cellSize / 2, ry: cellSize / 2 }, `cell-${index}`); } if (cellShape === 'rounded') { return /*#__PURE__*/_jsx(Rect, { ...cellProps, rx: 2, ry: 2 }, `cell-${index}`); } // Default square shape return /*#__PURE__*/_jsx(Rect, { ...cellProps }, `cell-${index}`); }, [cellSize, cellSpacing, cellShape, mergedTheme.colors.border, cellStyle, handleCellPress, handleCellLongPress, handleCellPressIn, handleCellPressOut, handleCellDoublePress, mergedAnimationConfig, mergedGestureConfig.hapticFeedback, processedData.length]); // Render month labels for calendar layout const renderMonthLabels = useCallback(() => { if (!showMonthLabels || layout !== 'calendar' || !calendarLayout) { return null; } return calendarLayout.monthBoundaries.map((month, index) => { const x = month.x * (cellSize + cellSpacing); const y = -mergedTheme.typography.fontSize - 5; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, ...labelStyle, children: month.month }, `month-${index}`); }); }, [showMonthLabels, layout, calendarLayout, cellSize, cellSpacing, mergedTheme.typography, mergedTheme.colors.text, labelStyle]); // Render weekday labels for calendar layout const renderWeekdayLabels = useCallback(() => { if (!showWeekdayLabels || layout !== 'calendar') { return null; } const weekdays = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; const x = -mergedTheme.typography.fontSize - 5; return weekdays.map((day, index) => { const y = index * (cellSize + cellSpacing) + cellSize / 2 + mergedTheme.typography.fontSize / 2; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, textAnchor: "middle", ...labelStyle, children: day }, `weekday-${index}`); }); }, [showWeekdayLabels, layout, cellSize, cellSpacing, mergedTheme.typography, mergedTheme.colors.text, labelStyle]); // Render time-based labels const renderTimeBasedLabels = useCallback(() => { if (!showTimeLabels || !timeBasedLayouts) { return null; } const labels = []; // Daily layout - hour labels if (layout === 'daily' && timeBasedLayouts.daily) { const hourLabels = timeBasedLayouts.daily.timeBoundaries.map((boundary, index) => { const x = boundary.x * (cellSize + cellSpacing); const y = -mergedTheme.typography.fontSize - 5; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, ...labelStyle, children: boundary.hour }, `hour-${index}`); }); labels.push(...hourLabels); } // Weekly layout - day labels if (layout === 'weekly' && timeBasedLayouts.weekly) { const dayLabels = timeBasedLayouts.weekly.dayBoundaries.map((boundary, index) => { const x = boundary.x * (cellSize + cellSpacing); const y = -mergedTheme.typography.fontSize - 5; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, ...labelStyle, children: boundary.day }, `day-${index}`); }); labels.push(...dayLabels); } // Monthly layout - week labels if (layout === 'monthly' && timeBasedLayouts.monthly) { const weekLabels = timeBasedLayouts.monthly.weekBoundaries.map((boundary, index) => { const x = -mergedTheme.typography.fontSize - 5; const y = boundary.week * (cellSize + cellSpacing) + cellSize / 2; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, ...labelStyle, children: `W${boundary.week}` }, `week-${index}`); }); labels.push(...weekLabels); } // Yearly layout - month labels if (layout === 'yearly' && timeBasedLayouts.yearly) { const monthLabels = timeBasedLayouts.yearly.monthBoundaries.map((boundary, index) => { const x = boundary.x * (cellSize + cellSpacing); const y = -mergedTheme.typography.fontSize - 5; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, ...labelStyle, children: boundary.month }, `month-${index}`); }); labels.push(...monthLabels); } // Custom range layout - period labels if (layout === 'customRange' && timeBasedLayouts.customRange) { const periodLabels = timeBasedLayouts.customRange.periodBoundaries.map((boundary, index) => { const x = boundary.x * (cellSize + cellSpacing); const y = -mergedTheme.typography.fontSize - 5; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, ...labelStyle, children: boundary.period }, `period-${index}`); }); labels.push(...periodLabels); } // Timeline scroll layout - scroll markers if (layout === 'timelineScroll' && timeBasedLayouts.timelineScroll) { const scrollLabels = timeBasedLayouts.timelineScroll.scrollMarkers.map((marker, index) => { const x = marker.position * (cellSize + cellSpacing); const y = -mergedTheme.typography.fontSize - 5; return /*#__PURE__*/_jsx(SvgText, { x: x, y: y, fontSize: mergedTheme.typography.fontSize, fontFamily: mergedTheme.typography.fontFamily, fontWeight: mergedTheme.typography.fontWeight, fill: mergedTheme.colors.text, ...labelStyle, children: marker.label }, `scroll-${index}`); }); labels.push(...scrollLabels); } return labels; }, [showTimeLabels, layout, timeBasedLayouts, cellSize, cellSpacing, mergedTheme.typography, mergedTheme.colors.text, labelStyle]); // Calculate SVG viewBox with padding for labels const viewBoxPadding = { left: showWeekdayLabels && layout === 'calendar' || showTimeLabels && (layout === 'monthly' || layout === 'yearly') ? mergedTheme.typography.fontSize + 10 : 0, top: showMonthLabels && layout === 'calendar' || showTimeLabels && (layout === 'daily' || layout === 'weekly' || layout === 'yearly' || layout === 'customRange' || layout === 'timelineScroll') ? mergedTheme.typography.fontSize + 10 : 0 }; const viewBoxWidth = finalWidth + viewBoxPadding.left; const viewBoxHeight = finalHeight + viewBoxPadding.top; return /*#__PURE__*/_jsxs(View, { style: [styles.container, { backgroundColor: mergedTheme.colors.background }, style], accessible: true, accessibilityLabel: accessibility.label, accessibilityRole: accessibility.role, onLayout: handleContainerLayout, children: [/*#__PURE__*/_jsx(Svg, { ref: gestureRef, width: viewBoxWidth, height: viewBoxHeight, viewBox: `-${viewBoxPadding.left} -${viewBoxPadding.top} ${viewBoxWidth} ${viewBoxHeight}`, children: /*#__PURE__*/_jsxs(G, { children: [renderMonthLabels(), renderWeekdayLabels(), renderTimeBasedLabels(), processedData.map(renderCell)] }) }), tooltipData && /*#__PURE__*/_jsx(Tooltip, { data: tooltipData.data, cellPosition: tooltipData.position, cellSize: cellSize, config: mergedTooltipConfig, theme: mergedTheme, containerDimensions: containerDimensions, visible: tooltipData.visible })] }); }; const styles = StyleSheet.create({ container: { alignItems: 'center', justifyContent: 'center', position: 'relative' } }); export default Heatmap; //# sourceMappingURL=Heatmap.js.map