ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React로 Gauge Chart 만들기
    Language/React 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사용해서 커스터 마이징하니깐 몇시간이면 뚝딱이다.

    댓글

Designed by Tistory.