feat: 添加睡眠阶段时间轴组件,优化睡眠数据可视化
This commit is contained in:
323
components/sleep/SleepStageTimeline.tsx
Normal file
323
components/sleep/SleepStageTimeline.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
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 chartWidth = 320;
|
||||
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);
|
||||
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: 0
|
||||
});
|
||||
|
||||
// 根据睡眠总时长动态调整时间间隔
|
||||
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;
|
||||
|
||||
// 检查与前一个标签的间距
|
||||
const lastLabel = labels[labels.length - 1];
|
||||
if (x - lastLabel.x >= minLabelSpacing && x <= chartWidth - 30) {
|
||||
labels.push({
|
||||
time: stepTime.format('HH:mm'),
|
||||
x: x
|
||||
});
|
||||
}
|
||||
|
||||
stepCount++;
|
||||
}
|
||||
|
||||
// 总是显示结束时间,但要确保与前一个标签有足够间距
|
||||
const endX = chartWidth - 25;
|
||||
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={chartWidth} 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}
|
||||
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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user