Language/React
React로 Gauge Chart 만들기
건담아빠
2025. 2. 27. 17:07

2년전 차트로 화면을 표시할때 비슷한 차트 라이브러리를 찾고 커스터마이징해서 작업하는데 시간이 꽤 많이 소요되었다. 찾는것도 일이지만 직접 만들지 않았다보니 커스터마이징이 헬일경우가 많다.
이번에 리뉴얼이 많이 되면서 비슷한 차트를 찾기도 힘들고 유지보수도 힘들것 같으니 직접 만들어보기로 했다.
1. Figma에서 화면 캡쳐 및 GPT에서 html로 변환 요청
- 캡쳐 및 GTP 요청
이렇게 요청하면 react-gauge-char등 기존 라이브러리를 찾아서 사용하라고 한다.. OTL..

- 다시요청
예상대로 svg로 만드는 법을 알려준다. 대충 만들어 진 코드 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사용해서 커스터 마이징하니깐 몇시간이면 뚝딱이다.