통계시각화 - Part2 일일달성률 도넛차트

Project Diary/Next.js + Prisma + MariaDB (KiloFlow)

이 포스팅에서는 사용자별 일일 달성률을 시각화하는 도넛 차트를 구현하는 방법을 설명합니다.


 

프론트엔드

 

1. 일일 달성률 데이터 가져오기

우선, 일일 달성률 데이터를 가져와서 도넛 차트에 전달하는 작업을 합니다. 이 과정은 메인 페이지 컴포넌트에서 수행됩니다.

// pages/index.tsx

useEffect(() => {
  const fetchAchievement = async () => {
    const res = await fetch(`/api/achievement/get?user_id=${currentUser.user_id}&date=${dayjs(selectedDate).format('YYYY-MM-DD')}`);
    if (res.ok) {
      const data = await res.json();
      setAchievement(data.achievement);
    } else {
      setAchievement(0);
    }
  };

  fetchAchievement();
}, [currentUser, selectedDate]);

현재 사용자와 선택된 날짜에 해당하는 달성률 데이터를 가져옵니다.

 

 

2. 일일 달성률 도넛 차트 컴포넌트

일일 달성률을 시각화하기 위해 react-chartjs-2 라이브러리를 사용하여 도넛 차트를 구현합니다.

// components/main/dailyAchievement.tsx

import React, { useEffect, useState } from 'react';
import { Doughnut } from 'react-chartjs-2';
import styled from 'styled-components';
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Plugin, Chart } from 'chart.js';

// Chart.js 요소와 플러그인 등록
ChartJS.register(ArcElement, Tooltip, Legend);

interface DailyAchievementProps {
  achievement: number; // 달성률을 받는 프롭스 인터페이스
}

const DailyAchievement: React.FC<DailyAchievementProps> = ({ achievement }) => {
  // 차트 데이터 상태
  const [chartData, setChartData] = useState({
    labels: ['달성률'],
    datasets: [
      {
        data: [0, 100],
        backgroundColor: ['#36A2EB', '#FFFFFF'],
        hoverBackgroundColor: ['#36A2EB', '#FFFFFF'],
        borderWidth: 0,
      },
    ],
  });

  // 도넛 차트 중앙 텍스트 상태
  const [centerText, setCenterText] = useState(`${achievement}%`);
  // 달성률에 따른 메시지 상태
  const [message, setMessage] = useState(['']);

  // 달성률에 따른 메시지 설정
  useEffect(() => {
    let allMessage = '';
    if (achievement < 30) {
      allMessage = '칼로리 섭취가 너무 적습니다. 건강한 식습관과 규칙적인 운동을 함께 유지하세요!';
    } else if (achievement >= 30 && achievement <= 40) {
      allMessage = '칼로리 섭취와 운동량이 조금 부족합니다. 더 노력해보세요!';
    } else if (achievement >= 41 && achievement <= 50) {
      allMessage = '칼로리 섭취와 운동이 조금씩 균형을 잡아가고 있습니다. 계속해보세요!';
    } else if (achievement >= 51 && achievement <= 60) {
      allMessage = '칼로리 섭취와 운동이 목표에 가까워지고 있습니다. 아주 잘하고 있어요!';
    } else if (achievement >= 61 && achievement <= 70) {
      allMessage = '칼로리 섭취와 운동이 적절합니다. 계속 이 상태를 유지하세요!';
    } else if (achievement >= 71 && achievement <= 80) {
      allMessage = '칼로리 섭취와 운동이 매우 좋습니다. 지금처럼 꾸준히 해보세요!';
    } else if (achievement >= 81 && achievement <= 90) {
      allMessage = '칼로리 섭취와 운동이 훌륭합니다. 계속해서 좋은 습관을 이어가세요!';
    } else if (achievement >= 91 && achievement <= 99) {
      allMessage = '칼로리 섭취와 운동이 거의 완벽합니다. 조금만 더 힘내세요!';
    } else if (achievement === 100) {
      allMessage = '오늘의 칼로리 섭취와 운동 목표를 완벽하게 달성했습니다. 지금처럼 건강한 생활을 지속하세요!';
    }
    setMessage(allMessage.split('.')); // 메시지를 구분하여 설정
  }, [achievement]);

  // 달성률 변경 시 차트 데이터 및 중앙 텍스트 업데이트
  useEffect(() => {
    setChartData({
      labels: ['달성률'],
      datasets: [
        {
          data: [achievement, 100 - achievement],
          backgroundColor: ['#36A2EB', '#FFFFFF'],
          hoverBackgroundColor: ['#36A2EB', '#FFFFFF'],
          borderWidth: 0,
        },
      ],
    });
    setCenterText(`${achievement}%`); // 중앙 텍스트 설정
  }, [achievement]);

  // 도넛 차트 옵션 설정
  const options = {
    cutout: '85%',
    plugins: {
      tooltip: {
        enabled: false, // 툴팁 비활성화
      },
      legend: {
        display: false, // 범례 비활성화
      },
    },
  };

  // 도넛 차트 중앙 텍스트 플러그인 생성 함수
  const createCenterTextPlugin = (): Plugin<'doughnut'> => {
    return {
      id: 'centerText',
      beforeDraw: (chart: Chart<'doughnut'>) => {
        const { ctx, width, height } = chart;
        ctx.restore();
        const fontSize = (height / 114).toFixed(2);
        ctx.font = `${fontSize}em sans-serif`;
        ctx.textBaseline = 'middle';
        ctx.fillStyle = '#36A2EB';
        ctx.save();
      },
    };
  };

  return (
    <AchievementWrapper>
      <div className='chartTitle'>
        <img src='../../mainStar.png' alt='' />
        <p>오늘의 목표 달성 상태를 확인하세요.</p>
      </div>
      <div className='doughnutChart'>
        <Doughnut data={chartData} options={options} plugins={[createCenterTextPlugin()]} />
        <p>{achievement}%</p>
      </div>
      <MessageWrapper>
        <p>{message[0]}.</p>
        <p>{message[1]}</p>
      </MessageWrapper>
    </AchievementWrapper>
  );
};

export default DailyAchievement;

(설명 : 주석참고)

 


 

백엔드

 

1. 달성률 조회 API 엔드포인트

// pages/api/achievement/get.ts

import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'GET') {
    const { user_id, date } = req.query;

    const startOfDay = new Date(date as string);
    startOfDay.setUTCHours(0, 0, 0, 0); // 날짜의 시작 시간 설정

    const endOfDay = new Date(date as string);
    endOfDay.setUTCHours(23, 59, 59, 999); // 날짜의 끝 시간 설정

    try {
      const achievement = await prisma.achievement.findFirst({
        where: {
          user_id: Number(user_id), // 사용자 ID와 날짜를 기준으로 달성률 조회
          date: {
            gte: startOfDay, // 날짜의 시작 시간 이후
            lte: endOfDay, // 날짜의 끝 시간 이전
          },
        },
      });

      if (!achievement) {
        return res.status(404).json({ error: 'Achievement not found' }); // 달성률이 없으면 404 에러 반환
      }

      res.status(200).json(achievement); // 성공적으로 데이터를 가져오면 200 상태와 함께 달성률 반환
    } catch (error) {
      console.error(error);
      res.status(500).json({ error: 'Failed to get achievement' }); // 에러 발생 시 500 상태와 에러 메시지 반환
    }
  } else {
    res.status(405).json({ error: 'Method not allowed' }); // 허용되지 않은 메서드 요청 시 405 에러 반환
  }
}