使用 Recharts 构建 React 睡眠催眠图
概述
睡眠催眠图(Hypnogram)是睡眠研究的标准可视化工具,用于展示睡眠过程中不同阶段的转换。本文将指导你使用 React 和 Recharts 构建一个专业且美观的睡眠催眠图组件。
什么是催眠图
催眠图用阶梯状图形表示睡眠阶段随时间的变化:
- 清醒期(Wake):0 阶段
- REM 睡眠:快速眼动睡眠
- N1:浅睡期 1
- N2:浅睡期 2
- N3:深睡期
每个睡眠阶段用不同的水平线表示,阶段转换时产生垂直跳跃。
Recharts 基础设置
首先安装 Recharts:
code
npm install recharts
# 或
yarn add recharts
Code collapsed
基础催眠图组件
数据结构定义
code
// types/sleep.ts
export enum SleepStage {
WAKE = 'Wake',
REM = 'REM',
N1 = 'N1',
N2 = 'N2',
N3 = 'N3'
}
export interface SleepDataPoint {
time: Date;
stage: SleepStage;
duration: number; // 分钟
}
export interface SleepSession {
date: Date;
bedTime: Date;
wakeTime: Date;
stages: SleepDataPoint[];
totalSleepTime: number;
sleepEfficiency: number;
}
Code collapsed
基础图表组件
code
// components/SleepHypnogram.tsx
import React, { useMemo } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid
} from 'recharts';
import { SleepStage } from '@/types/sleep';
// 睡眠阶段数值映射
const STAGE_VALUES: Record<SleepStage, number> = {
[SleepStage.WAKE]: 4,
[SleepStage.REM]: 3,
[SleepStage.N1]: 2,
[SleepStage.N2]: 1,
[SleepStage.N3]: 0
};
// 阶段颜色映射
const STAGE_COLORS: Record<SleepStage, string> = {
[SleepStage.WAKE]: '#ef4444', // red-500
[SleepStage.REM]: '#8b5cf6', // violet-500
[SleepStage.N1]: '#3b82f6', // blue-500
[SleepStage.N2]: '#06b6d4', // cyan-500
[SleepStage.N3]: '#10b981' // emerald-500
};
interface SleepHypnogramProps {
data: Array<{
time: string;
stage: SleepStage;
}>;
height?: number;
showGrid?: boolean;
}
export const SleepHypnogram: React.FC<SleepHypnogramProps> = ({
data,
height = 300,
showGrid = true
}) => {
// 转换数据为图表格式
const chartData = useMemo(() => {
return data.map((point, index) => ({
...point,
stageValue: STAGE_VALUES[point.stage],
index
}));
}, [data]);
// 自定义 Y 轴刻度标签
const renderYAxisTick = (value: number) => {
const stage = Object.entries(STAGE_VALUES).find(([_, v]) => v === value)?.[0];
return stage || '';
};
// 自定义提示框
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className: "bg-white rounded-lg shadow-lg p-3 border">
<p className: "text-sm text-gray-600">{data.time}</p>
<p className: "text-lg font-semibold" style={{ color: STAGE_COLORS[data.stage] }}>
{data.stage}
</p>
</div>
);
}
return null;
};
return (
<ResponsiveContainer width: "100%" height={height}>
<AreaChart
data={chartData}
margin={{ top: 10, right: 30, left: 40, bottom: 20 }}
>
{showGrid && (
<CartesianGrid
strokeDasharray: "3 3"
stroke: "#e5e7eb"
vertical={false}
/>
)}
<XAxis
dataKey: "time"
tick={{ fontSize: 12 }}
stroke: "#6b7280"
/>
<YAxis
tickFormatter={renderYAxisTick}
domain={[-0.5, 4.5]}
ticks={[0, 1, 2, 3, 4]}
tick={{ fontSize: 12 }}
stroke: "#6b7280"
/>
<Tooltip content={<CustomTooltip />} />
<Area
type: "stepAfter"
dataKey: "stageValue"
stroke: "#6366f1"
strokeWidth={2}
fill: "url(#sleepGradient)"
/>
<defs>
<linearGradient id: "sleepGradient" x1: "0" y1: "0" x2: "0" y2: "1">
<stop offset: "5%" stopColor: "#6366f1" stopOpacity={0.3}/>
<stop offset: "95%" stopColor: "#6366f1" stopOpacity={0.05}/>
</linearGradient>
</defs>
</AreaChart>
</ResponsiveContainer>
);
};
Code collapsed
增强版催眠图组件
添加睡眠阶段图例
code
// components/SleepStageLegend.tsx
import React from 'react';
import { SleepStage, STAGE_COLORS } from '@/types/sleep';
interface StageInfo {
stage: SleepStage;
label: string;
description: string;
}
const STAGE_INFO: StageInfo[] = [
{
stage: SleepStage.WAKE,
label: '清醒期',
description: '完全清醒状态'
},
{
stage: SleepStage.REM,
label: '快速眼动期',
description: '做梦阶段'
},
{
stage: SleepStage.N1,
label: '浅睡期 1',
description: '入睡阶段'
},
{
stage: SleepStage.N2,
label: '浅睡期 2',
description: '轻度睡眠'
},
{
stage: SleepStage.N3,
label: '深睡期',
description: '恢复性睡眠'
}
];
export const SleepStageLegend: React.FC = () => {
return (
<div className: "flex flex-wrap gap-4 justify-center">
{STAGE_INFO.map(({ stage, label, description }) => (
<div key={stage} className: "flex items-center gap-2">
<div
className: "w-4 h-4 rounded"
style={{ backgroundColor: STAGE_COLORS[stage] }}
/>
<div className: "text-sm">
<span className: "font-medium">{label}</span>
<span className: "text-gray-500 ml-1">({stage})</span>
</div>
</div>
))}
</div>
);
};
Code collapsed
添加睡眠统计面板
code
// components/SleepStatistics.tsx
import React, { useMemo } from 'react';
import { SleepStage, SleepDataPoint } from '@/types/sleep';
interface SleepStatisticsProps {
stages: SleepDataPoint[];
}
export const SleepStatistics: React.FC<SleepStatisticsProps> = ({ stages }) => {
const stats = useMemo(() => {
// 计算各阶段持续时间
const stageDurations = stages.reduce((acc, point) => {
acc[point.stage] = (acc[point.stage] || 0) + point.duration;
return acc;
}, {} as Record<SleepStage, number>);
const totalDuration = Object.values(stageDurations).reduce((a, b) => a + b, 0);
// 计算百分比
const stagePercentages = Object.entries(stageDurations).reduce((acc, [stage, duration]) => {
acc[stage as SleepStage] = (duration / totalDuration) * 100;
return acc;
}, {} as Record<SleepStage, number>);
return {
stageDurations,
stagePercentages,
totalDuration
};
}, [stages]);
const formatDuration = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}小时${mins}分钟`;
};
return (
<div className: "grid grid-cols-2 md:grid-cols-5 gap-4">
{Object.entries(stats.stagePercentages).map(([stage, percentage]) => (
<div
key={stage}
className: "bg-white rounded-lg p-4 shadow-sm border"
>
<div className: "text-2xl font-bold" style={{ color: STAGE_COLORS[stage as SleepStage] }}>
{percentage.toFixed(1)}%
</div>
<div className: "text-sm text-gray-600 mt-1">{stage}</div>
<div className: "text-xs text-gray-500 mt-1">
{formatDuration(stats.stageDurations[stage as SleepStage])}
</div>
</div>
))}
</div>
);
};
Code collapsed
完整的睡眠分析页面
code
// app/[locale]/(app)/sleep-analysis/page.tsx
import React, { useState, useEffect } from 'react';
import { SleepHypnogram } from '@/components/SleepHypnogram';
import { SleepStageLegend } from '@/components/SleepStageLegend';
import { SleepStatistics } from '@/components/SleepStatistics';
import { SleepSession, SleepStage } from '@/types/sleep';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
// 模拟数据生成
const generateMockSleepData = (): SleepSession => {
const stages: SleepStage[] = [
SleepStage.N1, SleepStage.N2, SleepStage.N3, SleepStage.N2,
SleepStage.REM, SleepStage.N2, SleepStage.N3, SleepStage.N2,
SleepStage.REM, SleepStage.N2, SleepStage.REM, SleepStage.WAKE
];
const data = [];
let currentTime = new Date();
currentTime.setHours(23, 0, 0, 0); // 晚上11点开始
for (let i = 0; i < 50; i++) {
const stage = stages[Math.floor(Math.random() * stages.length)];
const duration = 15 + Math.floor(Math.random() * 20); // 15-35分钟
data.push({
time: new Date(currentTime),
stage,
duration
});
currentTime = new Date(currentTime.getTime() + duration * 60000);
}
return {
date: new Date(),
bedTime: data[0].time,
wakeTime: data[data.length - 1].time,
stages: data,
totalSleepTime: data.reduce((sum, d) => sum + d.duration, 0),
sleepEfficiency: 85 + Math.random() * 10
};
};
export default function SleepAnalysisPage() {
const [sleepSession, setSleepSession] = useState<SleepSession | null>(null);
const [selectedDate, setSelectedDate] = useState(new Date());
useEffect(() => {
// 实际应用中从 API 获取数据
const fetchData = async () => {
// const response = await fetch(`/api/sleep/${selectedDate.toISOString()}`);
// const data = await response.json();
// setSleepSession(data);
// 使用模拟数据
setSleepSession(generateMockSleepData());
};
fetchData();
}, [selectedDate]);
if (!sleepSession) {
return (
<div className: "flex items-center justify-center h-screen">
<div className: "animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
// 转换数据为图表格式
const chartData = sleepSession.stages.map((point, index) => ({
time: point.time.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
stage: point.stage
}));
return (
<div className: "container mx-auto px-4 py-8 space-y-6">
<div>
<h1 className: "text-3xl font-bold">睡眠分析</h1>
<p className: "text-gray-600 mt-2">
{selectedDate.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
{/* 睡眠质量概览 */}
<div className: "grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle>总睡眠时间</CardTitle>
</CardHeader>
<CardContent>
<div className: "text-3xl font-bold">
{Math.floor(sleepSession.totalSleepTime / 60)}小时
{sleepSession.totalSleepTime % 60}分钟
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>睡眠效率</CardTitle>
</CardHeader>
<CardContent>
<div className: "text-3xl font-bold">
{sleepSession.sleepEfficiency.toFixed(1)}%
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>入睡时间</CardTitle>
</CardHeader>
<CardContent>
<div className: "text-3xl font-bold">
{sleepSession.bedTime.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
</CardContent>
</Card>
</div>
{/* 催眠图 */}
<Card>
<CardHeader>
<CardTitle>睡眠阶段变化</CardTitle>
<CardDescription>显示睡眠过程中各阶段的转换</CardDescription>
</CardHeader>
<CardContent>
<SleepStageLegend />
<div className: "mt-6">
<SleepHypnogram data={chartData} height={400} />
</div>
</CardContent>
</Card>
{/* 睡眠阶段统计 */}
<Card>
<CardHeader>
<CardTitle>睡眠阶段分布</CardTitle>
<CardDescription>各睡眠阶段的持续时间占比</CardDescription>
</CardHeader>
<CardContent>
<SleepStatistics stages={sleepSession.stages} />
</CardContent>
</Card>
</div>
);
}
Code collapsed
高级功能:可交互的催眠图
添加点击事件和详细信息
code
// components/InteractiveHypnogram.tsx
import React, { useState } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
Brush
} from 'recharts';
import { SleepDataPoint, SleepStage } from '@/types/sleep';
interface InteractiveHypnogramProps {
data: Array<{
time: string;
stage: SleepStage;
stageValue: number;
duration: number;
}>;
}
export const InteractiveHypnogram: React.FC<InteractiveHypnogramProps> = ({ data }) => {
const [selectedData, setSelectedData] = useState<typeof data[0] | null>(null);
const handleClick = (data: any) => {
if (data && data.activePayload) {
setSelectedData(data.activePayload[0].payload);
}
};
return (
<div className: "space-y-4">
<ResponsiveContainer width: "100%" height={400}>
<AreaChart
data={data}
onClick={handleClick}
margin={{ top: 10, right: 30, left: 40, bottom: 20 }}
>
<CartesianGrid strokeDasharray: "3 3" stroke: "#e5e7eb" vertical={false} />
<XAxis dataKey: "time" tick={{ fontSize: 12 }} stroke: "#6b7280" />
<YAxis
tickFormatter={(value) => {
const stages = ['N3', 'N2', 'N1', 'REM', 'Wake'];
return stages[value] || '';
}}
domain={[-0.5, 4.5]}
ticks={[0, 1, 2, 3, 4]}
tick={{ fontSize: 12 }}
stroke: "#6b7280"
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className: "bg-white rounded-lg shadow-lg p-4 border">
<p className: "text-sm text-gray-600">{data.time}</p>
<p className: "text-lg font-semibold">{data.stage}</p>
<p className: "text-sm text-gray-500">持续: {data.duration}分钟</p>
</div>
);
}
return null;
}}
/>
<Area
type: "stepAfter"
dataKey: "stageValue"
stroke: "#6366f1"
strokeWidth={2}
fill: "url(#sleepGradient)"
/>
<Brush dataKey: "time" height={30} />
</AreaChart>
</ResponsiveContainer>
{selectedData && (
<Card>
<CardHeader>
<CardTitle>阶段详情</CardTitle>
</CardHeader>
<CardContent>
<dl className: "grid grid-cols-2 gap-4">
<div>
<dt className: "text-sm text-gray-600">时间</dt>
<dd className: "font-semibold">{selectedData.time}</dd>
</div>
<div>
<dt className: "text-sm text-gray-600">阶段</dt>
<dd className: "font-semibold">{selectedData.stage}</dd>
</div>
<div>
<dt className: "text-sm text-gray-600">持续时间</dt>
<dd className: "font-semibold">{selectedData.duration} 分钟</dd>
</div>
</dl>
</CardContent>
</Card>
)}
</div>
);
};
Code collapsed
性能优化建议
- 数据抽样:对于长时间记录,按固定间隔抽样显示
- 虚拟化:使用 react-window 处理大数据集
- 懒加载:分阶段加载历史睡眠数据
- 缓存:使用 useMemo 缓存数据处理结果