Files
digital-pilates/components/ActivityHeatMap.tsx
richarjiang b93a863e25 feat: 添加用户活动热力图组件
在个人页面新增活动热力图展示组件,并实现浮动动画效果优化统计卡片交互体验。
2025-08-21 15:22:16 +08:00

330 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, number>();
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 (
<View style={[styles.container, { backgroundColor: colors.card }]}>
{/* 标题和统计 */}
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
6 {activityStats.activeDays}
</Text>
<View style={[styles.statsBadge, { backgroundColor: colors.ornamentPrimary }]}>
<Text style={[styles.statsText, { color: colors.primary }]}>
{activityStats.activeRate}%
</Text>
</View>
</View>
</View>
{/* 活动热力图 */}
<View style={styles.heatMapContainer}>
{/* 热力图网格 */}
<View style={styles.gridContainer}>
{organizeDataByWeeks.map((week, weekIndex) => (
<View key={weekIndex} style={[styles.week, { width: cellSize }]}>
{week.map((day, dayIndex) => (
<View
key={day.date}
style={[
styles.cell,
{
backgroundColor: getActivityColor(day.level),
width: cellSize,
height: cellSize,
borderRadius: Math.max(cellSize * 0.15, 1.5),
},
]}
/>
))}
</View>
))}
</View>
{/* 月份标签 */}
<View style={styles.monthLabelsContainer}>
{getMonthLabels.map((monthData, index) => {
// 计算标签位置:基于周的位置,但要确保标签不重叠
const basePosition = (monthData.position / (weeksToShow - 1)) * 100;
// 限制位置范围,避免标签超出边界
const leftPercentage = Math.max(0, Math.min(90, basePosition));
return (
<Text
key={`${monthData.label}-${index}`}
style={[
styles.monthLabel,
{
color: colors.textMuted,
position: 'absolute',
left: `${leftPercentage}%`
}
]}
>
{monthData.label}
</Text>
);
})}
</View>
</View>
{/* 图例 */}
<View style={styles.legend}>
<Text style={[styles.legendText, { color: colors.textMuted }]}></Text>
<View style={styles.legendColors}>
{[0, 1, 2, 3, 4].map((level) => (
<View
key={level}
style={[
styles.legendCell,
{
backgroundColor: getActivityColor(level),
borderColor: colors.border,
},
]}
/>
))}
</View>
<Text style={[styles.legendText, { color: colors.textMuted }]}></Text>
</View>
</View>
);
};
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;