import { Colors } from '@/constants/Colors'; import { useAppSelector } from '@/hooks/redux'; import { useColorScheme } from '@/hooks/useColorScheme'; import dayjs from 'dayjs'; import React, { useMemo } from 'react'; import { Dimensions, StyleSheet, Text, View } from 'react-native'; const ActivityHeatMap = () => { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; const activityData = useAppSelector(stat => stat.user.activityHistory); // 获取屏幕宽度,计算适合的网格大小 const screenWidth = Dimensions.get('window').width; const containerPadding = 40; // 卡片的左右padding const availableWidth = screenWidth - containerPadding; // 计算小方块的尺寸(显示最近26周,约6个月) const weeksToShow = 26; const gapBetweenWeeks = 4; // 周与周之间的间距 const totalGaps = (weeksToShow - 1) * gapBetweenWeeks; const cellWidth = (availableWidth - totalGaps) / weeksToShow; const cellSize = Math.max(Math.floor(cellWidth), 8); // 确保最小尺寸 // 生成最近6个月的活动数据 const generateActivityData = useMemo(() => { const data: { date: string; level: number }[] = []; const today = dayjs(); const startDate = today.subtract(weeksToShow * 7, 'day'); // 创建服务端数据的日期映射,便于快速查找 const activityMap = new Map(); if (activityData && activityData.length > 0) { activityData.forEach(item => { activityMap.set(item.date, item.level); }); } for (let i = 0; i <= weeksToShow * 7; i++) { const currentDate = startDate.add(i, 'day'); const dateString = currentDate.format('YYYY-MM-DD'); // 如果服务端有对应日期的数据,使用服务端的 level;否则为 0 const level = activityMap.get(dateString) || 0; data.push({ date: dateString, level, }); } return data; }, [activityData, weeksToShow]); console.log('generateActivityData', generateActivityData); // 根据活跃度计算颜色 const getActivityColor = (level: number): string => { // 由于useColorScheme总是返回'light',我们直接使用浅色主题的颜色 switch (level) { case 0: return colors.separator; // 使用主题分隔线色 case 1: case 2: return 'rgba(135,206,235,0.4)'; case 3: return 'rgba(135,206,235,0.65)'; case 4: default: return colors.primary; } }; // 将数据按周组织 const organizeDataByWeeks = useMemo(() => { const weeks: { date: string; level: number }[][] = []; for (let week = 0; week < weeksToShow; week++) { const weekData: { date: string; level: number }[] = []; for (let day = 1; day <= 7; day++) { const dataIndex = week * 7 + day; if (dataIndex < generateActivityData.length) { weekData.push(generateActivityData[dataIndex] as { date: string; level: number }); } } weeks.push(weekData); } return weeks; }, [generateActivityData, weeksToShow]); console.log('organizeDataByWeeks', organizeDataByWeeks); // 获取月份标签(简化的月份标签系统) const getMonthLabels = useMemo(() => { const monthNames = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; // 简单策略:均匀分布4-5个月份标签 const totalWeeks = weeksToShow; const labelPositions: { label: string; position: number }[] = []; // 在25%、50%、75%位置放置标签 const positions = [ Math.floor(totalWeeks * 0.1), Math.floor(totalWeeks * 0.35), Math.floor(totalWeeks * 0.65), Math.floor(totalWeeks * 0.9) ]; positions.forEach((weekIndex) => { if (weekIndex < organizeDataByWeeks.length && organizeDataByWeeks[weekIndex].length > 0) { const firstDay = dayjs(organizeDataByWeeks[weekIndex][0].date); const month = firstDay.month(); labelPositions.push({ label: monthNames[month], position: weekIndex }); } }); return labelPositions; }, [organizeDataByWeeks, weeksToShow]); // 计算活动统计 const activityStats = useMemo(() => { const totalDays = generateActivityData.length; const activeDays = generateActivityData.filter(d => d.level > 0).length; const totalActivity = generateActivityData.reduce((sum, d) => sum + d.level, 0); return { totalDays, activeDays, totalActivity, activeRate: Math.round((activeDays / totalDays) * 100) }; }, [generateActivityData]); return ( {/* 标题和统计 */} 最近6个月活跃 {activityStats.activeDays} 天 {activityStats.activeRate}% {/* 活动热力图 */} {/* 热力图网格 */} {organizeDataByWeeks.map((week, weekIndex) => ( {week.map((day, dayIndex) => ( ))} ))} {/* 月份标签 */} {getMonthLabels.map((monthData, index) => { // 计算标签位置:基于周的位置,但要确保标签不重叠 const basePosition = (monthData.position / (weeksToShow - 1)) * 100; // 限制位置范围,避免标签超出边界 const leftPercentage = Math.max(0, Math.min(90, basePosition)); return ( {monthData.label} ); })} {/* 图例 */} {[0, 1, 2, 3, 4].map((level) => ( ))} ); }; const styles = StyleSheet.create({ container: { borderRadius: 16, padding: 20, marginBottom: 20, shadowColor: 'rgba(135,206,235,0.25)', shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.12, shadowRadius: 6, elevation: 3, borderWidth: 1, borderColor: 'rgba(135,206,235,0.08)', }, header: { marginBottom: 8, }, titleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, }, title: { fontSize: 18, fontWeight: 'bold', }, statsBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 12, }, statsText: { fontSize: 13, fontWeight: '600', }, subtitle: { fontSize: 14, lineHeight: 20, }, heatMapContainer: { marginBottom: 18, }, gridContainer: { flexDirection: 'row', marginBottom: 12, paddingHorizontal: 2, gap: 2, // 统一的周间距 }, week: { flexDirection: 'column', gap: 1.5, flex: 1, alignItems: 'center', }, cell: { borderWidth: 0, }, monthLabelsContainer: { position: 'relative', height: 16, marginTop: 4, paddingHorizontal: 2, }, monthLabel: { fontSize: 11, fontWeight: '500', opacity: 0.8, transform: [{ translateX: -12 }], minWidth: 24, textAlign: 'center', }, legend: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 10, }, legendText: { fontSize: 10, opacity: 0.7, }, legendColors: { flexDirection: 'row', gap: 4, }, legendCell: { width: 8, height: 8, borderRadius: 2.5, borderWidth: 0.5, }, }); export default ActivityHeatMap;