Files
digital-pilates/components/sleep/SleepStageTimeline.tsx

325 lines
9.8 KiB
TypeScript
Raw Permalink 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 { useColorScheme } from '@/hooks/useColorScheme';
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';
export type SleepStageTimelineProps = {
sleepSamples: SleepSample[];
bedtime: string;
wakeupTime: string;
onInfoPress?: () => void;
};
export const SleepStageTimeline = ({
sleepSamples,
bedtime,
wakeupTime,
onInfoPress
}: SleepStageTimelineProps) => {
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 (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: colorTokens.textSecondary }]}>
</Text>
</View>
</View>
);
}
return (
<View style={[styles.container, { backgroundColor: colorTokens.background }]}>
{/* 标题栏 */}
<View style={styles.header}>
<Text style={[styles.title, { color: colorTokens.text }]}></Text>
{onInfoPress && (
<TouchableOpacity style={styles.infoButton} onPress={onInfoPress}>
<Ionicons name="help-circle-outline" size={20} color={colorTokens.textSecondary} />
</TouchableOpacity>
)}
</View>
{/* 睡眠时间范围 */}
<View style={styles.timeRange}>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
{formatTime(bedtime)}
</Text>
</View>
<View style={styles.timePoint}>
<Text style={[styles.timeLabel, { color: colorTokens.textSecondary }]}></Text>
<Text style={[styles.timeValue, { color: colorTokens.text }]}>
{formatTime(wakeupTime)}
</Text>
</View>
</View>
{/* SVG 图表 */}
<View style={styles.chartContainer}>
<Svg width={containerWidth} height={chartHeight}>
{/* 绘制睡眠阶段条形图 */}
{timelineData.map((segment, index) => (
<Rect
key={index}
x={segment.x}
y={timelineY}
width={segment.width}
height={timelineHeight}
fill={segment.color}
rx={2}
/>
))}
{/* 绘制时间刻度标签 */}
{timeLabels.map((label, index) => (
<React.Fragment key={index}>
{/* 刻度线 */}
<Rect
x={label.x - 0.5}
y={timelineY + timelineHeight}
width={1}
height={6}
fill={colorTokens.border}
/>
{/* 时间标签 */}
<SvgText
x={label.x}
y={timeScaleY}
fontSize={11}
fill={colorTokens.textSecondary}
textAnchor="middle"
>
{label.time}
</SvgText>
</React.Fragment>
))}
</Svg>
</View>
{/* 图例 */}
<View style={styles.legend}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Deep) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Core) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.REM) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: getSleepStageColor(SleepStage.Awake) }]} />
<Text style={[styles.legendText, { color: colorTokens.textSecondary }]}></Text>
</View>
</View>
</View>
</View>
);
};
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',
},
});