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

 

구현 포인트

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

히트 맵을 사용하여 달력 구현

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%"}
      />
    </>
  );
}

 

 

결과

 

 

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

 

 

 

'React' 카테고리의 다른 글

React.memo, useMemo, useCallback역할 및 차이점  (2) 2024.10.14
달력 (히트맵) 만들기 (2)  (0) 2024.09.12
개별 토글 리스트  (0) 2024.09.11
useform 추가 데이터  (0) 2024.09.06
react-cookie  (1) 2024.09.04
minsun309