image

달력 (히트맵) 만들기 (1)

태그
ReactJavascript
상세설명히트맵으로 달력 만들고 해당 날에 포스팅된 글 있으면 표시하기
작성일자2024.06.07

개인 웹사이트를 만들면서 깃 헙에 있는 잔디처럼 하루에 포스팅 된 글 수를 한 눈에 보고 싶어 아펙스 차트의 히트맵을 사용하여 잔디와 조금 다르게 구현했다.

구현 포인트

  • 월 별 히트 맵 ( 달력처럼 구현 )
  • 페이지 진입 시 오늘 날짜 해당 월에 대해 보여주기
  • 년, 월 선택 가능하게
  • 마우스 호버 시 포스트 갯 수 툴 팁으로 보여주기
  • 히트 맵을 사용하여 달력 구현

    dynamic 을 사용하여 ApexCharts를 import 시켰다.

    ( 관련 글 : https://min-sun.vercel.app/blog/f3df2808-d7ac-4b7f-b2b4-0acda188806d )

    달력 제작 시 고려해야 할 사항

    전달, 다음날 날짜가 같은 주에 있는 첫 번째 주, 마지막 주 일 자 수를 구해야 한다.

    이번 달 날짜 수

    먼저 Home에서 받아온 year, month을 오늘 날짜로 설정 후 오늘 날짜가 포함된 달의 첫날, 마지막 날을 구한 후 그 사이의 날짜 차이를 계산하여 이번 달이 몇 일로 구성되어있는지 구한다.

    첫날, 마지막 날의 밀리 초 단위로 시간 차이를 계산한 값을 1일(24시간)로 나누어 해당 월의 일 수를 계산하고, 절대 값을 취해 양수로 만든다.

    const today = new Date(`${year}-${month}`);
    const yearChart = today.getFullYear();
    const monthChart = today.getMonth() + 1;
    
    const firstDay = new Date(yearChart, today.getMonth(), 1);
    const lastDay = new Date(yearChart, monthChart, 0);
    const diffDate = firstDay.getTime() - lastDay.getTime();
    const daysDifference = Math.abs(diffDate / (1000 * 60 * 60 * 24));

    이번 달 날짜와 해당 날의 요일

    for문을 통해 dateArray에 이번 달의 모든 날짜를 순회하면서 [날짜, 요일] 형태 ( ex. ['2024-01-01', 1] )로 만들어 배열에 추가합니다.

    let dateArray: [string, number][] = [];
    
    const DateArray = () => {
        dateArray = [];
        for (let i = 1; i <= daysDifference + 1; i++) {
         const monthDate = new Date(year, Number(month) - 1, i);
    
         const korDate = new Date(
            monthDate.getTime() - monthDate.getTimezoneOffset() * 60000
         ).toISOString();
    
         dateArray.push([korDate.split("T")[0], monthDate.getDay()]);
      }
    }

    주 별 일 수 구하기

    첫 번째 주일 수

    dateArray[0][1] 을 통해 첫날의 요일을 구할 수 있어( 일요일이 0이고 토요일이 6임 )7 - dateArray[0][1] 을 계산하여 해당 월의 첫 주에서 몇 일이 있는 지 구한다.

    ex. 1월의 첫날은 월요일(1) ['2024-01-01', 1] 이어서 7 - 1 이면 6으로 1월의 첫 주는 6일이 있다.

    첫 번째 주를 제외한 나머지 주일 수

    전체 일자 수 에서 첫 번 째 수를 뺀 후 7로 나눈다.

    const firstWeekDays = dateArray.length > 0 ? 7 - dateArray[0][1] : 0;
    const excaptFirstWeek = (dateArray.length - firstWeekDays) / 7;

    첫 번째 주를 제외한 나머지 주

    middleWeek() 함수는 시작 요일( startWeekDay )과 마지막 요일( lastWeekDay )을 받아와서 해당 범위에 해당하는 dateArray의 일부를 추출한다.

    히트맵의 기본 구성이 배열 안 객체들로 data 값은 2개의 값이 있는 객체여야 한다.

    // 예시
    [
     {
      name: 'Metric2',
      data: generateData(18, {
       min: 0,
       max: 90
      })
     },
     ...
    ]

    그래서 data: [ { x: ‘ 날짜 ( date[0] )‘, y : ‘ 포스트 수 ( createPostCount[date[0]] : 관련 설명은 다음 글에서.. ) 또는 0( 포스트가 없는 경우) ‘ } ] 구성으로 secondWeekData 같이 각 주 마다middleWeek() 함수를 통해 각 주 일자들과 포스트 수를 구했다.

    const middleWeek = (startWeekDay: number, lastWeekDay: number) => {
         return dateArray
            ?.slice(firstWeekDays + startWeekDay, firstWeekDays + lastWeekDay)
            .map((date) => ({
              x: date[0],
              y: createPostCount[date[0]] ? createPostCount[date[0]] : 0,
         }));
    };
    
    const secondWeekData = middleWeek(0, 7);
    const thirdWeekData = middleWeek(7, 14);
    const fourthWeekData = middleWeek(14, 21);
    const fifthhWeekData = middleWeek(21, 28);

    만약 그 달이 6주일 경우

    첫 주를 제외한 나머지 주차 수가 4보다 큰 지를 확인하여 4보다 크면 실행하는 코드로 6주차가 있을 경우 2주부터 5주차 까지 의 일수는 28일이므로 firstWeekDays에 28을 더해 29일부터 해당 월의 끝 까지의 데이터를 추출하여 sixthWeekDate에 데이터를 저장한다.

    if (excaptFirstWeek > 4) {
          sixthWeekDate = dateArray?.slice(firstWeekDays + 28).map((date) => ({
            x: date[0],
            y: createPostCount[date[0]] ? createPostCount[date[0]] : 0,
          }));
       }

    첫 번째 주

    마지막 주차는 0번 부터 시작하기 때문에 상관없지만 첫 번째 주는 다르게 일자가 일요일에 시작할 수도 수요일에 시작 할 수 있기 때문에 다른 주차들과 다르네 1주차에 전달이 있을 경우 배열에 전달일이 있는 만큼 객체을 추가 해야 한다.

    다른 주차 들처럼 firstWeekData에 0 부터 firstWeekDays까지의 데이터를 추출하여 데이터를 저장한다.

    const firstWeekData = dateArray
          ?.slice(0, firstWeekDays)
          .map((_, index) => ({
            x: dateArray[index][0],
            y: createPostCount[dateArray[index][0]]
              ? createPostCount[dateArray[index][0]]
              : 0,
    }));

    그 후 firstWeekData 수가 7 미만 일 경우 7일에서 첫 번째 주 일 수를 뺀 만큼 for문을 순회해 x축에는 전달 일자와 y 축에는 동일한 -1값을 부여한다.

    noneDate 배열과 firstWeekData 배열을 합치고 중복을 제거한 후 firstWeekDate 배열에 저장한다. 히트맵에 같은 firstWeekDate 변수를 활용하기 위해 firstWeekData 수가 7일 경우에도 저장한다.

    let currentDay = new Date(firstDay);
    
    if (firstWeekData && firstWeekData.length < 7) {
          let noneDate: { x: string; y: number }[] = [];
          for (let i = 0; i < 7 - firstWeekData?.length; i++) {
            const previousMonth = new Date(
              currentDay.setDate(currentDay.getDate() - 1)
            )
              .toISOString()
              .split("T")[0];
    
            noneDate.push({
              x: previousMonth,
              y: -1,
            });
          }
    
          const addNoneDate = [...noneDate, ...firstWeekData];
    
          // 중복제거
          addNoneDate.forEach((date) => {
            if (!firstWeekDate.find((item) => item.x === date.x)) {
              firstWeekDate.push(date);
            }
          });
        } else if (firstWeekData && firstWeekData.length === 7) {
          firstWeekData.forEach((date) => {
            if (!firstWeekDate.find((item) => item.x === date.x)) {
              firstWeekDate.push(date);
            }
          });
    }

    여기까지가 DateArray() 함수에서 이루어지며 return을 통해 각 주의 date을 내보낸다.

    const DateArray = () => {
     ....
     return [
          firstWeekDate,
          ...
          sixthWeekDate,
        ];
    };
    
    const firstWeekDatas = DateArray()[0];
    const secondWeekDatas = DateArray()[1];
    const thirdWeekDatas = DateArray()[2];
    const fourthWeekDatas = DateArray()[3];
    const fifthWeekDatas = DateArray()[4];
    const sixWeekDatas = DateArray()[5];

    HeatMap 대입

    series

    series 에 6추자 여부에 따라 데이터를 넣는다.

    const commonDate = [
        {
          name: "5주",
          data: fifthWeekDatas,
        },
        {
          name: "4주",
          data: fourthWeekDatas,
        },
        {
          name: "3주",
          data: thirdWeekDatas,
        },
        {
          name: "2주",
          data: secondWeekDatas,
        },
        {
          name: "1주",
          data: firstWeekDatas,
        },
      ];
    
      const state: ApexOptions = {
        series:
          sixthWeekDate.length > 0
            ? [
                {
                  name: "6주",
                  data: sixWeekDatas,
                },
                ...commonDate,
              ]
            : [...commonDate],
      };

    options

    설정한 옵션 중 중요한 포인트

  • plotOptions
  • plotOptions 에서 ranges에 따른 색상을 지정 할 수 있어 y의 값( 포스트 수 )이 0일 경우 "#94A3B8" 색이 입히도록 했다.

     plotOptions: {
       heatmap: {
           colorScale: {
              ranges: [
                {
                  from: 0,
                  to: 0,
                  color: "#94A3B8",
                },
              ],
           },
        },
    },

  • tooltip
  • 기본 tooltip 은 원하는 값을 보여 주지 않아 커스텀했다.

    data.y 의 값이 -1 일 경우에는 tooltip이 안 보이게 하여 전달에 대한 정보를 가리고 그 외에는 마우스 호버 시 날짜와 해당 날짜의 포스트 수를 볼 수 있게 구성했다.

    tooltip: {
        enabled: true,
        custom: function ({ series, seriesIndex, dataPointIndex, w }) {
            const data = w.globals.initialSeries[seriesIndex].data[dataPointIndex];
    
            if (data.y === -1) {
              return "";
            }
    
            return (
              "<div class='py-1 px-2 rounded-md'>" +
              "<span class='text-xs'>" +
              data.x.replace(/-/g, ".") +
              "</span>" +
              "<span class='text-xs font-bold'> : " +
              data.y +
              " post" +
              "</span>" +
              "</div>"
            );
       },
    },

    options 전체 코드

    const options: ApexOptions = {
        chart: {
          type: "heatmap",
          zoom: {
            enabled: false,
          },
          toolbar: {
            show: false,
          },
          animations: {
            enabled: false,
          },
        },
        dataLabels: {
          enabled: false,
        },
        legend: {
          show: false,
        },
        colors: ["#2c82f2"],
        plotOptions: {
          heatmap: {
            colorScale: {
              ranges: [
                {
                  from: 0,
                  to: 0,
                  color: "#94A3B8",
                },
              ],
            },
          },
        },
        states: {
          normal: {
            filter: {
              type: "none",
            },
          },
          hover: {
            filter: {
              type: "none",
            },
          },
          active: {
            allowMultipleDataPointsSelection: false,
            filter: {
              type: "none",
            },
          },
        },
        stroke: {
          width: 3,
          colors: theme === "light" ? ["#F3F4F6"] : ["#1f2937"],
        },
        yaxis: {
          labels: {
            style: {
              fontSize: "10px",
              colors: theme === "light" ? "#000" : "#d5d6d8",
            },
          },
        },
        xaxis: {
          type: "category",
          tickPlacement: "between",
          categories: ["일", "월", "화", "수", "목", "금", "토"],
          crosshairs: {
            show: false,
          },
          tooltip: {
            enabled: false,
          },
          labels: {
            style: {
              colors: theme === "light" ? "#000" : "#d5d6d8",
            },
          },
          axisBorder: {
            show: false,
          },
          axisTicks: {
            show: false,
          },
        },
        tooltip: {
          enabled: true,
          custom: function ({ series, seriesIndex, dataPointIndex, w }) {
            const data = w.globals.initialSeries[seriesIndex].data[dataPointIndex];
    
            if (data.y === -1) {
              return "";
            }
    
            return (
              "<div class='py-1 px-2 rounded-md'>" +
              "<span class='text-xs'>" +
              data.x.replace(/-/g, ".") +
              "</span>" +
              "<span class='text-xs font-bold'> : " +
              data.y +
              " post" +
              "</span>" +
              "</div>"
            );
          },
        },
      };

    style

    heatmap 기본 색상을 "#2c82f2" 설정해서 -1에 대한 블럭 색상은 css ( tailwind 사용함 ) 로 배경 색과 동일하게 주어 해당 달에 대한 날짜만 보이게 했다.

    /*hearmap : -1값*/
    rect[val="-1"] {
      @apply fill-[#f3f4f6] dark:fill-[#1f2937];
    }

    PostHeatMap.tsx 전체 코드

    import dynamic from "next/dynamic";
    const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });
    import { ApexOptions } from "apexcharts";
    import { useEffect } from "react";
    import { useTheme } from "next-themes";
    import { PostCountType, PostHeatMapType } from "@/InterfaceGather";
    
    export default function PostHeatMap({ blogs, year, month }: PostHeatMapType) {
      const { theme } = useTheme();
      const today = new Date(`${year}-${month}`);
      const yearChart = today.getFullYear();
      const monthChart = today.getMonth() + 1;
    
      //작성일자
      const createPost = blogs.results.map((x: { created_time: string }) => {
        const create = new Date(x.created_time);
        const korDate = new Date(
          create.getTime() - create.getTimezoneOffset() * 60000
        )
          .toISOString()
          .split("T")[0];
        return korDate;
      });
    
      const createPostCount: PostCountType = {};
      createPost.forEach((x: string | number) => {
        createPostCount[x] = (createPostCount[x] || 0) + 1;
      });
    
      // 이번달 1일, 마지막 일
      const firstDay = new Date(yearChart, today.getMonth(), 1);
      const lastDay = new Date(yearChart, monthChart, 0);
      const diffDate = firstDay.getTime() - lastDay.getTime();
      const daysDifference = Math.abs(diffDate / (1000 * 60 * 60 * 24));
    
      let dateArray: [string, number][] = [];
      let firstWeekDate: { x: string; y: number }[] = [];
      let sixthWeekDate: { x: string; y: number }[] = [];
    
      useEffect(() => {
        DateArray();
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, []);
    
      const DateArray = () => {
        dateArray = [];
    
        for (let i = 1; i <= daysDifference + 1; i++) {
          const monthDate = new Date(year, Number(month) - 1, i);
    
          const korDate = new Date(
            monthDate.getTime() - monthDate.getTimezoneOffset() * 60000
          ).toISOString();
    
          dateArray.push([korDate.split("T")[0], monthDate.getDay()]);
        }
    
        const firstWeekDays = dateArray.length > 0 ? 7 - dateArray[0][1] : 0;
        const excaptFirstWeek = (dateArray.length - firstWeekDays) / 7;
    
        const firstWeekData = dateArray
          ?.slice(0, firstWeekDays)
          .map((_, index) => ({
            x: dateArray[index][0],
            y: createPostCount[dateArray[index][0]]
              ? createPostCount[dateArray[index][0]]
              : 0,
          }));
    
        const middleWeek = (startWeekDay: number, lastWeekDay: number) => {
          return dateArray
            ?.slice(firstWeekDays + startWeekDay, firstWeekDays + lastWeekDay)
            .map((date) => ({
              x: date[0],
              y: createPostCount[date[0]] ? createPostCount[date[0]] : 0,
            }));
        };
    
        const secondWeekData = middleWeek(0, 7);
        const thirdWeekData = middleWeek(7, 14);
        const fourthWeekData = middleWeek(14, 21);
        const fifthhWeekData = middleWeek(21, 28);
    
        if (excaptFirstWeek > 4) {
          sixthWeekDate = dateArray?.slice(firstWeekDays + 28).map((date) => ({
            x: date[0],
            y: createPostCount[date[0]] ? createPostCount[date[0]] : 0,
          }));
        }
    
        let currentDay = new Date(firstDay);
    
        if (firstWeekData && firstWeekData.length < 7) {
          let noneDate: { x: string; y: number }[] = [];
          for (let i = 0; i < 7 - firstWeekData?.length; i++) {
            const previousMonth = new Date(
              currentDay.setDate(currentDay.getDate() - 1)
            )
              .toISOString()
              .split("T")[0];
    
            noneDate.push({
              x: previousMonth,
              y: -1,
            });
          }
    
          const addNoneDate = [...noneDate, ...firstWeekData];
    
          // 중복제거
          addNoneDate.forEach((date) => {
            if (!firstWeekDate.find((item) => item.x === date.x)) {
              firstWeekDate.push(date);
            }
          });
        } else if (firstWeekData && firstWeekData.length === 7) {
          firstWeekData.forEach((date) => {
            if (!firstWeekDate.find((item) => item.x === date.x)) {
              firstWeekDate.push(date);
            }
          });
        }
    
        return [
          firstWeekDate,
          secondWeekData,
          thirdWeekData,
          fourthWeekData,
          fifthhWeekData,
          sixthWeekDate,
        ];
      };
    
      const firstWeekDatas = DateArray()[0];
      const secondWeekDatas = DateArray()[1];
      const thirdWeekDatas = DateArray()[2];
      const fourthWeekDatas = DateArray()[3];
      const fifthWeekDatas = DateArray()[4];
      const sixWeekDatas = DateArray()[5];
    
      const commonDate = [
        {
          name: "5주",
          data: fifthWeekDatas,
        },
        {
          name: "4주",
          data: fourthWeekDatas,
        },
        {
          name: "3주",
          data: thirdWeekDatas,
        },
        {
          name: "2주",
          data: secondWeekDatas,
        },
        {
          name: "1주",
          data: firstWeekDatas,
        },
      ];
    
      const state: ApexOptions = {
        series:
          sixthWeekDate.length > 0
            ? [
                {
                  name: "6주",
                  data: sixWeekDatas,
                },
                ...commonDate,
              ]
            : [...commonDate],
      };
    
      const options: ApexOptions = {
        chart: {
          type: "heatmap",
    
          zoom: {
            enabled: false,
          },
          toolbar: {
            show: false,
          },
          animations: {
            enabled: false,
          },
        },
        dataLabels: {
          enabled: false,
        },
        legend: {
          show: false,
        },
        colors: ["#2c82f2"],
        plotOptions: {
          heatmap: {
            colorScale: {
              ranges: [
                {
                  from: 0,
                  to: 0,
                  color: "#94A3B8",
                },
              ],
            },
          },
        },
        states: {
          normal: {
            filter: {
              type: "none",
            },
          },
          hover: {
            filter: {
              type: "none",
            },
          },
          active: {
            allowMultipleDataPointsSelection: false,
            filter: {
              type: "none",
            },
          },
        },
        stroke: {
          width: 3,
          colors: theme === "light" ? ["#F3F4F6"] : ["#1f2937"],
        },
        yaxis: {
          labels: {
            style: {
              fontSize: "10px",
              colors: theme === "light" ? "#000" : "#d5d6d8",
            },
          },
        },
        xaxis: {
          type: "category",
          tickPlacement: "between",
          categories: ["일", "월", "화", "수", "목", "금", "토"],
          crosshairs: {
            show: false,
          },
          tooltip: {
            enabled: false,
          },
          labels: {
            style: {
              colors: theme === "light" ? "#000" : "#d5d6d8",
            },
          },
          axisBorder: {
            show: false,
          },
          axisTicks: {
            show: false,
          },
        },
        tooltip: {
          enabled: true,
    
          custom: function ({ series, seriesIndex, dataPointIndex, w }) {
            const data = w.globals.initialSeries[seriesIndex].data[dataPointIndex];
    
            if (data.y === -1) {
              return "";
            }
    
            return (
              "<div class='py-1 px-2 rounded-md'>" +
              "<span class='text-xs'>" +
              data.x.replace(/-/g, ".") +
              "</span>" +
              "<span class='text-xs font-bold'> : " +
              data.y +
              " post" +
              "</span>" +
              "</div>"
            );
          },
        },
      };
    
      return (
        <>
          <ApexCharts
            options={options}
            series={state.series}
            type="heatmap"
            width={"98%"}
            height={"86%"}
          />
        </>
      );
    }

    결과

    image

    날짜 별 포스트 수 구하는 방법은 다음 글 이어서…