康心伴Logo
康心伴WellAlly
前端开发

使用 Recharts 构建 React 睡眠催眠图

5 分钟阅读

使用 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

性能优化建议

  1. 数据抽样:对于长时间记录,按固定间隔抽样显示
  2. 虚拟化:使用 react-window 处理大数据集
  3. 懒加载:分阶段加载历史睡眠数据
  4. 缓存:使用 useMemo 缓存数据处理结果

参考资料

#

文章标签

react
recharts
睡眠监测
可视化
健康科技

觉得这篇文章有帮助?

立即体验康心伴,开始您的健康管理之旅