MainBlogProjectsDocs
components
zandi

Zandi (HeatMap)

Zandi (HeatMap)

Zandi

Zandi

일
μ›”
ν™”
수
λͺ©
금
ν† 

3μ›”

2μ›”

1μ›”

12μ›”

11μ›”

Required

Tailwind μ„€μΉ˜

tailwindCSS

Dependencies μ„€μΉ˜

npx shadcn-ui@latest add popover

Component 생성

폴더ꡬ쑰

/zandi
  /components
    zandi.tsx
    zandi-item.tsx
  /utils
    index.ts
  /types
    index.ts
  index.ts

zandi/types/index.ts

export interface HeatMapData {
  date: string;
  value: number;
}
 
interface PaletteItem {
  threshold: number;
  color: string;
}
 
export type Palette = PaletteItem[];

zandi/utils/index.ts

import { HeatMapData } from '../types';
 
const formatDateStr = (date: Date): string => {
  return date.toISOString().split('T')[0];
};
 
export const generateDates = (startDate: Date, endDate: Date): Date[] => {
  const dates = [];
  for (let day = startDate; day <= endDate; day.setDate(day.getDate() + 1)) {
    dates.push(new Date(day));
  }
  return dates;
};
 
export const groupByWeeks = (dates: Date[], heatMapData: HeatMapData[]) => {
  const heatMapDataMap = new Map(
    heatMapData.map((item) => [item.date, item.value])
  );
 
  const weeks: {
    dates: HeatMapData[];
    month: number;
    isFirstWeek: boolean;
  }[] = [];
 
  let week: HeatMapData[] = [];
  let lastMonth: number | null = null;
 
  dates.forEach((date, idx) => {
    const month = date.getMonth();
    const dateStr = formatDateStr(date);
    const heatMapValue = heatMapDataMap.get(dateStr) || 0;
 
    if (date.getDay() === 0 && 0 < week.length) {
      weeks.push({
        dates: week,
        month: month + 1,
        isFirstWeek: lastMonth !== month,
      });
      week = [];
 
      lastMonth = month;
    }
    week.push({
      date: dateStr,
      value: heatMapValue,
    });
    if (idx === dates.length - 1) {
      weeks.push({
        dates: week,
        month: month + 1,
        isFirstWeek: lastMonth !== month,
      });
    }
  });
 
  return weeks.reverse(); // μ΅œμ‹  데이터가 μœ„λ‘œκ°€λ„λ‘ 뒀집기
};

zandi/components/zandi.tsx

import * as React from 'react';
 
import { HeatMapData, Palette } from '../types';
import { ZandiItem } from './zandi-item';
import { generateDates, groupByWeeks } from '../utils';
import { cn } from '@/lib/utils';
 
interface ZandiProps {
  heatMapData: HeatMapData[];
  startDate: Date;
  endDate: Date;
  palette: Palette;
  locale?: string;
  className?: string;
}
 
export const Zandi = ({
  heatMapData,
  startDate,
  endDate,
  palette,
  locale = 'default',
  className,
}: ZandiProps) => {
  const dates = React.useMemo(
    () => generateDates(startDate, endDate),
    [startDate, endDate]
  );
  const weeks = React.useMemo(
    () => groupByWeeks(dates, heatMapData),
    [dates, heatMapData]
  );
 
  const dayHeaders = React.useMemo(() => {
    const format = new Intl.DateTimeFormat(locale, { weekday: 'short' });
    return Array.from({ length: 7 }, (_, i) =>
      format.format(new Date(1970, 0, i + 4))
    );
  }, [locale]);
 
  palette.sort((a, b) => a.threshold - b.threshold);
 
  return (
    <div className={cn('relative flex flex-col gap-2', className)}>
      <div className="grid grid-cols-7 gap-2 mb-2 text-foreground/70 text-xs text-center">
        {dayHeaders.map((day, index) => (
          <div key={index}>{day}</div>
        ))}
      </div>
 
      {weeks.map((week, idx) => (
        <div key={idx} className={'grid grid-cols-7 gap-2'}>
          {/* ν•΄λ‹Ή 주에 달이 λ°”λ€Œλ©΄ μ™Όμͺ½μ— 달 ν‘œμ‹œ */}
          {week.isFirstWeek ? (
            <p className="absolute -left-10 text-xs">{week.month}μ›”</p>
          ) : null}
          {week.dates.map((date, dateIdx) => (
            <ZandiItem key={dateIdx} date={date} palette={palette} />
          ))}
        </div>
      ))}
    </div>
  );
};

zandi/components/zandi-item.tsx

import { HeatMapData, Palette } from '../types';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
 
interface ZandiItemProps {
  date: HeatMapData;
  palette: Palette;
}
 
export const ZandiItem = ({ date, palette }: ZandiItemProps) => {
  const { value } = date;
  let backgroundColor = '';
 
  for (let i = palette.length - 1; 0 <= i; --i) {
    if (palette[i].threshold <= value) {
      backgroundColor = palette[i].color;
      break;
    }
  }
 
  return (
    <Popover>
      <PopoverTrigger>
        <div
          key={date.date}
          style={{ backgroundColor }}
          className="bg-[#262626] w-6 h-6 rounded-sm"
        />
      </PopoverTrigger>
      <PopoverContent className="w-28 space-y-1">
        <p className="text-sm font-semibold text-center">{date.value}</p>
        <p className="text-xs text-foreground/70 text-center">{date.date}</p>
      </PopoverContent>
    </Popover>
  );
};

zandi/index.ts

export { Zandi } from './components/zandi';
export { ZandiItem } from './components/zandi-item';
export * from './utils';
export * from './types';

How to customize Zandi?

interface ZandiProps {
  heatMapData: HeatMapData[]; // μž”λ”” 데이터
  startDate: Date;
  endDate: Date;
  palette: Palette; // νŒ”λ ˆνŠΈμ˜ μ»¬λŸ¬μ™€ 경계
  locale?: string; // Intl.DateTimeFormat props 확인 ("default" | "en-US" | "ko-KR" ...)
  className?: string; // μΆ”κ°€ μŠ€νƒ€μΌ
}
 
interface PaletteItem {
  threshold: number; // value <= threshold λ©΄ ν•΄λ‹Ή color μž”λ”” ν‘œμ‹œ
  color: string;
}
 
export type Palette = PaletteItem[];

κ·Έ μ™Έ μŠ€νƒ€μΌλ§μ€ μ»΄ν¬λ„ŒνŠΈμ—μ„œ 직접 (zandi.tsx, zandi-item.tsx)