개인 웹사이트를 만들면서 깃 헙에 있는 잔디처럼 하루에 포스팅 된 글 수를 한 눈에 보고 싶어 아펙스 차트의 히트맵을 사용하여 잔디와 조금 다르게 구현했다.
구현 포인트
히트 맵을 사용하여 달력 구현
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 에서 ranges에 따른 색상을 지정 할 수 있어 y의 값( 포스트 수 )이 0일 경우 "#94A3B8" 색이 입히도록 했다.
plotOptions: { heatmap: { colorScale: { ranges: [ { from: 0, to: 0, color: "#94A3B8", }, ], }, }, },
기본 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%"} /> </> ); }
결과
날짜 별 포스트 수 구하는 방법은 다음 글 이어서…