src/components/MonthHeatmap.tsx
Rama main · réplica de lectura del repositorio original.
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { COLORS, FONT_SIZE, RADIUS, SPACING } from '../constants';
import { formatMoneyShort, getDaysInMonth, getFirstDayOfMonth, getToday } from '../utils/dateUtils';
interface Props {
year: number;
month: number; // 1-12
dailyExpense: Record<string, number>; // date -> 支出金额
maxExpense: number; // 当月最大单日支出
selectedDate: string;
onSelectDate: (date: string) => void;
}
// 月历热力图(颜色深浅 = 消费高低,色盲友好)
export default function MonthHeatmap({
year,
month,
dailyExpense,
maxExpense,
selectedDate,
onSelectDate,
}: Props) {
// 一屏 42 格,「今天」每帧只算一次(此前每格各 new Date() 一次)
const today = getToday();
const days = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const cells: (number | null)[] = [
...Array.from({ length: firstDay }, () => null),
...Array.from({ length: days }, (_, i) => i + 1),
];
// 按周分行(每行 7 格 flex 平分,替代百分比魔数,任意屏宽不溢出)
// 末行不满 7 格时补空白占位,否则剩余日期格 flex:1 平分整行会异常超宽
const weeks: (number | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) {
const week = cells.slice(i, i + 7);
while (week.length < 7) week.push(null);
weeks.push(week);
}
// 按消费金额分 4 级(颜色由浅到深);返回 [背景色, 是否深底(文字用白)]
const levelStyle = (amount: number): [string, boolean] => {
if (amount <= 0) return [COLORS.surface, false];
const ratio = maxExpense > 0 ? amount / maxExpense : 0;
if (ratio < 0.25) return [COLORS.heatmap[0], false];
if (ratio < 0.5) return [COLORS.heatmap[1], false];
if (ratio < 0.75) return [COLORS.heatmap[2], false];
return [COLORS.heatmap[3], true]; // 最深档橙底,深灰字对比不足改白字
};
return (
<View>
<View style={styles.weekHeader}>
{['日', '一', '二', '三', '四', '五', '六'].map((w) => (
<Text key={w} style={styles.weekLabel}>
{w}
</Text>
))}
</View>
<View style={styles.grid}>
{weeks.map((week, wi) => (
<View key={`week-${wi}`} style={styles.weekRow}>
{week.map((day, idx) => {
if (day === null) return <View key={`blank-${wi}-${idx}`} style={styles.cell} />;
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const amount = dailyExpense[dateStr] ?? 0;
const isSelected = dateStr === selectedDate;
const isToday = dateStr === today;
const [bg, isDeep] = levelStyle(amount);
return (
<Pressable
key={dateStr}
style={[
styles.cell,
{ backgroundColor: bg },
isSelected && styles.cellSelected,
isToday && !isSelected && styles.cellToday,
]}
onPress={() => onSelectDate(dateStr)}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
accessibilityLabel={`${month}月${day}日${amount > 0 ? `支出${formatMoneyShort(amount)}元` : '无支出'}`}
>
<Text
style={[
styles.dayText,
amount > 0 && styles.dayTextActive,
isDeep && !isSelected && styles.dayTextDeep,
isSelected && styles.dayTextSelected,
isToday && !isSelected && styles.dayTextToday,
]}
>
{day}
</Text>
{amount > 0 ? (
<Text
style={[
styles.amountText,
isDeep && !isSelected && styles.amountTextDeep,
isSelected && styles.dayTextSelected,
]}
numberOfLines={1}
>
{formatMoneyShort(amount)}
</Text>
) : null}
{isToday && !isSelected ? <View style={styles.todayDot} /> : null}
</Pressable>
);
})}
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
weekHeader: {
flexDirection: 'row',
marginBottom: SPACING.xs,
},
weekLabel: {
flex: 1,
textAlign: 'center',
fontSize: FONT_SIZE.xs,
color: COLORS.textTertiary,
},
grid: {
gap: 6,
},
weekRow: {
flexDirection: 'row',
gap: 4,
},
cell: {
flex: 1,
aspectRatio: 0.92,
borderRadius: RADIUS.xs,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: COLORS.borderSubtle,
paddingHorizontal: 1,
},
cellSelected: {
borderWidth: 2,
borderColor: COLORS.accent,
backgroundColor: COLORS.accentDark, // 实底强调(覆盖热力浅色),白字对比度约 5:1
},
cellToday: {
borderColor: COLORS.accent,
},
todayDot: {
position: 'absolute',
bottom: 3,
width: 4,
height: 4,
borderRadius: 2,
backgroundColor: COLORS.accent,
},
dayText: {
fontSize: FONT_SIZE.xs + 1,
color: COLORS.textTertiary,
fontWeight: '500',
},
dayTextActive: {
color: COLORS.text,
fontWeight: '700',
},
dayTextToday: {
color: COLORS.accentDark,
},
dayTextSelected: {
color: COLORS.white,
},
dayTextDeep: {
color: COLORS.white,
},
amountText: {
// 8.5sp 在手机上几乎看不清(xs-2.5),热力图金额是最常被读的数字,抬到 10sp
fontSize: FONT_SIZE.xs - 1,
color: 'rgba(45,45,45,0.75)',
fontWeight: '600',
marginTop: 1,
},
amountTextDeep: {
color: 'rgba(255,255,255,0.9)',
},
});