개인 웹사이트를 만들면서 깃 헙에 있는 잔디처럼 하루에 포스팅 된 글 수를 한 눈에 보고 싶어 아펙스 차트의 히트맵을 사용하여 잔디와 조금 다르게 구현했다.
구현 포인트
- 월 별 히트 맵 ( 달력처럼 구현 )
- 페이지 진입 시 오늘 날짜 해당 월에 대해 보여주기
- 년, 월 선택 가능하게
- 마우스 호버 시 포스트 갯 수 툴 팁으로 보여주기
히트 맵을 사용하여 달력 구현
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 |