@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
JavaScript
"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