Language/React

React로 Gauge Chart 만들기

건담아빠 2025. 2. 27. 17:07

2년전 차트로 화면을 표시할때 비슷한 차트 라이브러리를 찾고 커스터마이징해서 작업하는데 시간이 꽤 많이 소요되었다. 찾는것도 일이지만 직접 만들지 않았다보니 커스터마이징이 헬일경우가 많다.
이번에 리뉴얼이 많이 되면서 비슷한 차트를 찾기도 힘들고 유지보수도 힘들것 같으니 직접 만들어보기로 했다.
 

1. Figma에서 화면 캡쳐 및 GPT에서 html로 변환 요청

  • 캡쳐 및 GTP 요청

이렇게 요청하면 react-gauge-char등 기존 라이브러리를 찾아서 사용하라고 한다.. OTL..

이렇게 했더니 react-gauge-char 라이브러리를 사용하라고 한다.. OTL..

 
 

  • 다시요청

예상대로 svg로 만드는 법을 알려준다. 대충 만들어 진 코드 react에 복붙하면된다.

react에서 직접만드는 방법으로 다시요청한다.

 

2. React 적용 및 커스터 마이징

GPT로 요청한건 아직까지는 완벽하지 않다. 많이 부족하다.. 그래도 없는것 보다는!!
아래 겁나 커스터 마이징 해서 결과만만 정리해두자.

  • 호출코드
<div
  onClick={() =>
    setChartData([
      { label: 'AA', value: 30, color: 'red', tooltipText: 'ㅇㅇㅇㅇ' },
      { label: 'BB', value: 60, color: '#E8A43A' },
      { label: 'CC', value: 10, color: '#4B4B4B' },
    ])
  }
>
  test
</div>

<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '100px', backgroundColor: '#FFF' }}>
  <GaugeChart
    // data={[
    //   { label: 'A', value: 50, color: '#61B329', tooltipText: 'ㅇㅇㅇㅇ' },
    //   { label: 'B', value: 30, color: '#E8A43A' },
    //   { label: 'C', value: 20, color: '#4B4B4B' },
    // ]}
    data={chartData}
    width={200}
    height={200}
    gap={1.5}
    strokeWidth={16}
    hoverEffect={false}
    showTooltip={false}
    label={
      <div style={{ position: 'absolute', bottom: '5px' }}>
        <div style={{ fontWeight: 'bold' }}>50/30/20</div>
        <div style={{ color: '#666' }}>현재 공실</div>
      </div>
    }
  />
  <GaugeChart
    data={[
      { label: 'A', value: 1, color: '#FF0000' },
      { label: 'B', value: 2, color: '#00FF00' },
      { label: 'C', value: 3, color: '#0000FF' },
      { label: 'D', value: 4, color: '#FFFF00' },
    ]}
    width={250}
    height={260}
    gap={2}
    strokeWidth={10}
    hoverEffect={false}
    showTooltip={false}
    label="250 * 260, gap: 2, strokeWidth: 10"
  />
  <GaugeChart
    data={[
      { label: 'A', value: 40, color: '#FF5733' },
      { label: 'B', value: 30, color: '#33FF57' }, // 랜덤 색상 적용
      { label: 'C', value: 30, color: '#3498DB' },
    ]}
    width={300}
    height={330}
    gap={0}
    strokeWidth={20}
    label="300 * 330, gap: 0, strokeWidth: 20"
  />
</div>

 

  • GaugeChart
import React, { useState, useCallback, useMemo } from 'react';

// 랜덤 색상 생성 함수
const getRandomColor = () => {
  const letters = '0123456789ABCDEF';
  let color = '#';
  for (let i = 0; i < 6; i++) {
    color += letters[Math.floor(Math.random() * 16)];
  }
  return color;
};

interface ChartData {
  label?: string;
  value: number;
  color?: string;
  tooltipText?: string; // 툴팁에 표시할 텍스트 추가
}

interface GaugeChartProps {
  data?: ChartData[];
  gap?: number;
  strokeWidth?: number;
  width?: number;
  height?: number;
  label?: React.ReactNode;
  hoverEffect?: boolean; // 마우스 오버 시 강조 효과 적용 여부
  showTooltip?: boolean; // 툴팁 표시 여부
}

const GaugeChart: React.FC<GaugeChartProps> = ({
  data = [
    { label: 'A', value: 40, tooltipText: 'A: 40개' },
    { label: 'B', value: 30, tooltipText: 'B: 30개' },
    { label: 'C', value: 30, tooltipText: 'C: 30개' },
  ],
  gap = 2,
  strokeWidth = 6,
  width = 200,
  height = 120,
  label,
  hoverEffect = true, // 기본적으로 hover 효과 활성화
  showTooltip = true, // 기본적으로 툴팁 활성화
}) => {
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
  const [tooltipData, setTooltipData] = useState<string | null>(null);
  const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
  const [isMouseOverChart, setIsMouseOverChart] = useState(false); // 차트 영역 위에 마우스가 있는지 추적

  const radius = (width / 2) * 0.8; // 반지름 (차트 크기에 맞춰 자동 조정)
  const circumference = Math.PI * radius; // 반원의 둘레 길이
  const total = data.reduce((sum, item) => sum + item.value, 0); // 전체 값 합산

  // **gap과 strokeWidth를 차트 크기에 비례하도록 조정**
  const adjustedGap = (gap / 100) * circumference; // gap을 크기에 맞게 조정
  const adjustedStrokeWidth = (strokeWidth / 200) * width; // strokeWidth 비율 적용

  // 여백 개수 (첫 번째, 마지막 제외)
  const totalGaps = data.length - 1;
  const gapSize = totalGaps > 0 ? adjustedGap * totalGaps : 0; // 총 여백 길이
  const availableCircumference = circumference - gapSize; // 마지막 색상은 여백 제외

  // 색상 배열 생성 (색상이 없을 경우 랜덤 색상 사용)
  const formattedData = data.map((item, index) => ({
    ...item,
    color: item.color || getRandomColor(), // 지정된 색상이 없으면 랜덤 색상 적용
    ratio: (item.value / total) * availableCircumference, // 비율에 맞게 길이 조정
  }));

  // 마우스 이벤트 핸들러
  const handleMouseEnter = useCallback(
    (e: React.MouseEvent, index: number, item: (typeof formattedData)[0]) => {
      if (isMouseOverChart) {
        setHoveredIndex(index);
        if (showTooltip) {
          setTooltipData(item.tooltipText || `${item.label}: ${item.value}`);
          setTooltipPosition({ x: e.clientX, y: e.clientY - 40 });
        }
      }
    },
    [isMouseOverChart, showTooltip]
  );

  const handleMouseMove = useCallback(
    (e: React.MouseEvent, index: number) => {
      if (showTooltip && isMouseOverChart && hoveredIndex === index) {
        setTooltipPosition({ x: e.clientX, y: e.clientY - 40 });
      }
    },
    [showTooltip, isMouseOverChart, hoveredIndex]
  );

  // 차트 경로 생성 (메모이제이션으로 성능 최적화)
  const chartPaths = useMemo(() => {
    return formattedData.reduce<{ prevTotal: number; elements: JSX.Element[] }>(
      (acc, item, index) => {
        const prevTotal = acc.prevTotal;
        const dashArray = `${item.ratio} ${circumference}`;
        const dashOffset = `-${prevTotal}`;

        acc.elements.push(
          <path
            key={index}
            d={`M ${width * 0.1} ${height / 2} A ${radius} ${radius} 0 0 1 ${width * 0.9} ${height / 2}`}
            stroke={item.color}
            strokeWidth={hoverEffect && hoveredIndex === index ? adjustedStrokeWidth * 1.5 : adjustedStrokeWidth}
            fill="none"
            strokeDasharray={dashArray}
            strokeDashoffset={dashOffset}
            onMouseEnter={(e) => handleMouseEnter(e, index, item)}
            onMouseLeave={(e) => {
              // 각 path 요소에서 마우스가 벗어날 때 hoveredIndex를 null로 설정하지 않음
              // 이렇게 하면 마우스가 path 사이를 이동할 때 깜빡임 방지
              // 전체 차트에서 마우스가 벗어날 때만 hoveredIndex를 null로 설정
            }}
            onMouseMove={(e) => handleMouseMove(e, index)}
            style={{
              transition: 'stroke-width 0.2s ease-in-out',
              cursor: 'pointer',
            }}
          />
        );

        // 첫 번째와 마지막 선에는 gap 적용하지 않음
        if (index < totalGaps) {
          acc.prevTotal += item.ratio + adjustedGap;
        } else {
          acc.prevTotal += item.ratio;
        }

        return acc;
      },
      { prevTotal: 0, elements: [] }
    ).elements;
  }, [
    formattedData,
    hoveredIndex,
    circumference,
    radius,
    width,
    height,
    adjustedStrokeWidth,
    hoverEffect,
    totalGaps,
    adjustedGap,
    handleMouseEnter,
    handleMouseMove,
  ]);

  return (
    <div
      style={{
        textAlign: 'center',
        width: `${width}px`,
        position: 'relative',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
      }}
      onMouseLeave={() => {
        // 전체 차트 영역을 벗어나면 모든 상태 초기화
        setIsMouseOverChart(false);
        setHoveredIndex(null);
        setTooltipData(null);
      }}
      onMouseEnter={() => {
        setIsMouseOverChart(true);
      }}
    >
      {/* 차트 (SVG) */}
      <svg width={width} height={height / 2} viewBox={`0 0 ${width} ${height / 2}`}>
        {/* 배경 원 */}
        <path
          d={`M ${width * 0.1} ${height / 2} A ${radius} ${radius} 0 0 1 ${width * 0.9} ${height / 2}`}
          stroke="#E0E0E0"
          strokeWidth={adjustedStrokeWidth}
          fill="none"
        />
        {chartPaths}
      </svg>

      {label && label}

      {/* 툴팁 (showTooltip이 true일 때만 표시) */}
      {showTooltip && tooltipData && isMouseOverChart && (
        <div
          style={{
            position: 'fixed',
            top: `${tooltipPosition.y}px`,
            left: `${tooltipPosition.x}px`,
            background: 'rgba(0, 0, 0, 0.75)',
            color: '#FFF',
            padding: '6px 12px',
            borderRadius: '4px',
            fontSize: '14px',
            pointerEvents: 'none',
            transform: 'translate(-50%, -100%)',
            whiteSpace: 'nowrap',
            zIndex: 1000,
          }}
        >
          {tooltipData}
        </div>
      )}
    </div>
  );
};

export default GaugeChart;

 
완성
 

결과물

그럭저럭 만족할만하게 만들어 졌다.

 

사용법

interface GaugeChartProps {
  data?: ChartData[];
  gap?: number;
  strokeWidth?: number;
  width?: number;
  height?: number;
  label?: React.ReactNode;
  hoverEffect?: boolean; // 마우스 오버 시 강조 효과 적용 여부
  showTooltip?: boolean; // 툴팁 표시 여부
}

GaugeChartProps Interface에 기록된것처럼 사용법도 예상되지 아니한가? 그대로 때려 넣으면 된다.
 
AI대세가 맞긴 한가보다..
예전에는 이런거 만들려면 몇일 걸렸을텐데 GPT사용해서 커스터 마이징하니깐 몇시간이면 뚝딱이다.