import { Colors } from '@/constants/Colors'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useI18n } from '@/hooks/useI18n'; import { formatTime, getSleepStageColor, SleepStage, type SleepSample } from '@/utils/sleepHealthKit'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; import React, { useMemo } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Svg, { Rect, Text as SvgText } from 'react-native-svg'; import { StyleProp, ViewStyle } from 'react-native'; export type SleepStageTimelineProps = { sleepSamples: SleepSample[]; bedtime: string; wakeupTime: string; onInfoPress?: () => void; hideHeader?: boolean; style?: StyleProp; }; export const SleepStageTimeline = ({ sleepSamples, bedtime, wakeupTime, onInfoPress, hideHeader = false, style }: SleepStageTimelineProps) => { const { t } = useI18n(); const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const colorTokens = Colors[theme]; // 图表尺寸参数 const containerWidth = 320; const chartPadding = 25; // 左右边距,为时间标签预留空间 const chartWidth = containerWidth - chartPadding * 2; const chartHeight = 80; const timelineHeight = 32; const timelineY = 24; const timeScaleY = timelineY + timelineHeight + 16; // 计算时间范围和刻度 const { timelineData, timeLabels } = useMemo(() => { if (!bedtime || !wakeupTime || sleepSamples.length === 0) { return { timelineData: [], timeLabels: [] }; } const startTime = dayjs(bedtime); const endTime = dayjs(wakeupTime); const totalMinutes = endTime.diff(startTime, 'minute'); // 过滤相关睡眠阶段,排除InBed const relevantSamples = sleepSamples.filter(sample => sample.value !== SleepStage.InBed ); // 转换样本数据为时间轴数据 const data = relevantSamples.map(sample => { const sampleStart = dayjs(sample.startDate); const sampleEnd = dayjs(sample.endDate); const startOffset = sampleStart.diff(startTime, 'minute'); const duration = sampleEnd.diff(sampleStart, 'minute'); const x = Math.max(0, (startOffset / totalMinutes) * chartWidth) + chartPadding; const width = Math.max(2, (duration / totalMinutes) * chartWidth); return { x, width, stage: sample.value, color: getSleepStageColor(sample.value) }; }); // 智能生成时间标签,避免重合 const labels = []; const minLabelSpacing = 50; // 最小标签间距(像素) // 总是显示起始时间 labels.push({ time: startTime.format('HH:mm'), x: chartPadding }); // 根据睡眠总时长动态调整时间间隔 const sleepDurationHours = totalMinutes / 60; let timeStepMinutes; if (sleepDurationHours <= 4) { timeStepMinutes = 60; // 1小时间隔 } else if (sleepDurationHours <= 8) { timeStepMinutes = 120; // 2小时间隔 } else { timeStepMinutes = 180; // 3小时间隔 } // 添加中间时间标签,确保不重合 let currentTime = startTime; let stepCount = 1; while (currentTime.add(timeStepMinutes * stepCount, 'minute').isBefore(endTime)) { const stepTime = currentTime.add(timeStepMinutes * stepCount, 'minute'); const x = (stepTime.diff(startTime, 'minute') / totalMinutes) * chartWidth + chartPadding; // 检查与前一个标签的间距 const lastLabel = labels[labels.length - 1]; if (x - lastLabel.x >= minLabelSpacing && x <= containerWidth - chartPadding) { labels.push({ time: stepTime.format('HH:mm'), x: x }); } stepCount++; } // 总是显示结束时间,但要确保与前一个标签有足够间距 const endX = containerWidth - chartPadding; const lastLabel = labels[labels.length - 1]; if (endX - lastLabel.x >= minLabelSpacing) { labels.push({ time: endTime.format('HH:mm'), x: endX }); } else { // 如果空间不够,替换最后一个标签为结束时间 labels[labels.length - 1] = { time: endTime.format('HH:mm'), x: endX }; } return { timelineData: data, timeLabels: labels }; }, [sleepSamples, bedtime, wakeupTime, chartWidth]); // 如果没有数据,显示空状态 if (timelineData.length === 0) { return ( {!hideHeader && ( {t('sleepDetail.sleepStages')} {onInfoPress && ( )} )} {t('sleepDetail.noData')} ); } return ( {/* 标题栏 */} {!hideHeader && ( {t('sleepDetail.sleepStages')} {onInfoPress && ( )} )} {/* 睡眠时间范围 */} {t('sleepDetail.infoModalTitles.sleepTime')} {formatTime(bedtime)} {t('sleepDetail.sleepDuration')} {formatTime(wakeupTime)} {/* SVG 图表 */} {/* 绘制睡眠阶段条形图 */} {timelineData.map((segment, index) => ( ))} {/* 绘制时间刻度标签 */} {timeLabels.map((label, index) => ( {/* 刻度线 */} {/* 时间标签 */} {label.time} ))} {/* 图例 */} {t('sleepDetail.deep')} {t('sleepDetail.core')} {t('sleepDetail.rem')} {t('sleepDetail.awake')} ); }; const styles = StyleSheet.create({ container: { borderRadius: 16, padding: 16, marginBottom: 24, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 3, marginHorizontal: 4, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, }, title: { fontSize: 16, fontWeight: '600', }, infoButton: { padding: 4, }, timeRange: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, }, timePoint: { alignItems: 'center', }, timeLabel: { fontSize: 12, fontWeight: '500', marginBottom: 4, }, timeValue: { fontSize: 16, fontWeight: '700', letterSpacing: -0.2, }, chartContainer: { alignItems: 'center', marginBottom: 16, }, emptyState: { alignItems: 'center', paddingVertical: 32, }, emptyText: { fontSize: 14, fontStyle: 'italic', }, legend: { gap: 8, }, legendRow: { flexDirection: 'row', justifyContent: 'center', gap: 24, }, legendItem: { flexDirection: 'row', alignItems: 'center', gap: 6, }, legendDot: { width: 8, height: 8, borderRadius: 4, }, legendText: { fontSize: 12, fontWeight: '500', }, });