Files
digital-pilates/components/ActivityHeatMap.tsx
richarjiang c12329bc96 feat: 移除目标管理演示页面并优化相关组件
- 删除目标管理演示页面的代码,简化项目结构
- 更新底部导航,移除目标管理演示页面的路由
- 调整相关组件的样式和逻辑,确保界面一致性
- 优化颜色常量的使用,提升视觉效果
2025-08-22 21:24:31 +08:00

338 lines
9.5 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 => {
switch (level) {
case 0:
// 无活动:使用主题适配的背景色
return colors.separator;
case 1:
// 低活动:使用主题主色的浅色版本
return 'rgba(122, 90, 248, 0.15)'; // 浅色模式下的浅紫色
case 2:
// 中等活动:使用主题主色的中等透明度
return 'rgba(122, 90, 248, 0.35)'; // 浅色模式下的中等紫色
case 3:
// 高活动:使用主题主色的较高透明度
return 'rgba(122, 90, 248, 0.55)'; // 浅色模式下的较深紫色
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,
borderColor: colors.border,
shadowColor: 'rgba(122, 90, 248, 0.08)',
}]}>
{/* 标题和统计 */}
<View style={styles.header}>
<View style={styles.titleRow}>
<Text style={[styles.subtitle, { color: colors.textMuted }]}>
6 {activityStats.activeDays}
</Text>
<View style={[styles.statsBadge, {
backgroundColor: 'rgba(122, 90, 248, 0.1)'
}]}>
<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,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.12,
shadowRadius: 6,
elevation: 3,
},
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;