프로젝트에서 이런 모양의 조작 가능한 도넛 차트 개발을 요청받았다.
요구사항:
핵심 함수:
도넛차트의 총합은 100이고, 소수점은 없다. 그러므로 1당 3.6도로 계산한다.
부채꼴은 svg, path를 사용하여 그린다.
path 에서 호(arc)를 그리기 위해선 다음과 같은 코드를 사용한다.
A [x 반지름] [y 반지름] [x축 회전] [large-arc-flag] [sweep-flag] [종료 x지점] [종료 y지점]
a rx ry x-axis-rotation large-arc-flag sweep-flag dx dy
일반적으로 화면의 좌표체계에서는, x가 증가하면 오른쪽으로, y가 증가하면 아래로 이동한다. 따라서 sin, cos 함수에 들어가는 값이 커질 수록 좌측 하단에서 우측 상단으로 호를 그리며 이동하게 된다.
즉 이대로 사용하게 되면 0도가 되는 기준이 6시 방향이 되고, 반시계 방향으로 각도가 늘어나게 된다.
개발하는 도넛차트는 12시 방향을 0도로 잡고 시계방향으로 각도가 늘어나야 하므로 출력값에 대한 적절한 조치가 필요하다.
// 종료 x, y 지점 계산
const getEndCoordinates = (deg: number, radius: number) =>
[Math.sin, Math.cos].map((func) => func(((180 - deg) * Math.PI) / 180) * radius + radius);
180 - 각도
가 된다.function generateDonutSlicePath(deg: number, radius: number) {
const startX = radius;
const startY = 0;
const [endX, endY] = getEndCoordinates(deg, radius);
return `M ${startX} ${startY}
A ${radius} ${radius} 0 ${Number(deg > 180)} 1 ${endX} ${endY}
L ${radius} ${radius}`;
}
도넛 조각을 그리는 데에는 성공했는데, 모두 12시 방향부터 시작한다는 문제가 있다.
마지막 부채꼴(도넛 조각)의 호가 끝나는 좌표를 시작점으로 설정하여 그릴 수는 있지만, generateDonutSlice
함수의 추가적인 수정이 필요하며, 시작 지점이 변경됨으로 인해 생기는 부작용이 많았다.
마지막 부채꼴의 종료 각도만큼 다음 도넛를 시계방향으로 회전시켜 줌으로써 해결했다.
<svg viewBox={'0 0 200 200'} width={200} height={200}>
<path fill={'blue'} d={generateDonutSlicePath(90, 100)}/>
<path fill={'green'} d={generateDonutSlicePath(90, 100)} transform={'rotate(90)'}/>
<path fill={'red'} d={generateDonutSlicePath(90, 100)} transform={'rotate(180)'}/>
<path fill={'yellow'} d={generateDonutSlicePath(90, 100)} transform={'rotate(270)'}/>
</svg>
svg > path {
transform-origin: 50% 50%;
}
가운데에 하얀 원을 그려주면 도넛 모양이 완성된다.
<svg viewBox={'0 0 200 200'} width={200} height={200} style={{ margin: '50px 0 0 50px' }}>
...
<circle fill={'white'} cx={'50%'} cy={'50%'} r={80} />
</svg>
도넛차트 컴포넌트에 들어갈 필수 Props는 두 가지이다.
const Test: NextPage = () => {
const [values, setValues] = useState([30, 50, 20]); // 도합 100
return <DonutChart values={values} onChange={setValues} />;
};
const colors: string[] = ['#5CADD0', '#3587CE', '#5C68D0', '#A45CD0'];
const DonutChart = ({ className, values, onChange }: Props) => {
const [lastValues, setLastValues] = useState([30, 60, 100]); // 각 값들의 손잡이 위치(즉, 누적된 값)
// 도넛 조각 그리는 path 계산
const pies = lastValues.map((v, i, arr) => generateDonutSlicePath((i ? v - arr[i - 1] : v) * 3.6, 130));
// 입력된 value가 유효한지(합 100이 되는지) 확인
// 유효할 경우 수치 적용된 도넛차트 렌더
// 유효하지 않을 경우 마지막으로 유효했던 값 유지
useEffect(() => {
// 새 values의 총합이 100이 되지 않을 경우 적용하지 않음
if (values.reduce((a, b) => a + b) % 100) return;
const newValues = values.reduce<number[]>((acc, v) => {
acc.push(v + (acc.at(-1) ?? 0));
return acc;
}, []);
setLastValues(newValues);
}, [values]);
return (
<div className={classNames(cx('chart-wrap'), className)}>
<svg viewBox={'0 0 260 260'} width={260} height={260} transform={`rotate(${rotation.current * 3.6})`}>
{pies.map((v, i) => (
<path
className={cx('pie')}
key={i}
fill={colors[i]}
d={v}
transform={`rotate(${(lastValues[i - 1] ?? 0) * 3.6})`}
/>
))}
<circle fill={'white'} cx={'50%'} cy={'50%'} r={98} />
</svg>
</div>
);
};
const Test: NextPage = () => {
const [values, setValues] = useState([30, 40, 20, 10]); // 도합 100
return (
<div style={{ margin: '50px 0 0 50px' }}>
<DonutChart values={values} onChange={setValues} />
</div>
);
};
위와 같이 작성하면 아래와 같은 도넛차트가 그려진다.
개발하는 도넛차트는 각 손잡이를 드래그하여 두 값을 동시에 가감할 수 있어야 한다.
값의 조작을 위해서는 손잡이의 위치로부터 각도를 역산할 필요가 있는데, 이 때 atan2 함수가 사용된다.
앞에서 말했듯이, 화면상의 좌표체계는 좌측 상단이 영점이다.
atan2 함수는 cos, sin 함수와 달리 0도가 되는 기준선이 3시 방향이고, 시계방향으로 움직인다.
atan2 함수의 y값이 음수가 되면, 각도 또한 음수가 된다.
Math.atan2(-0, -1) === -180deg
function calcAnglePercent(x: number, y: number, radius: number) {
const degree = (Math.atan2(y - radius, x - radius) * 180) / Math.PI + 90;
const val = Math.round((degree + (degree >= 0 ? 0 : 360)) / 3.6);
return val ? val : 100;
}
손잡이 컴포넌트 코드:
const DonutChartKnob = ({ value, color, idx, onChange }: Props) => {
const [isDragging, setIsDragging] = useState(false);
const handleMouseDown = (e: React.MouseEvent) => {
const parent = e.currentTarget.parentElement;
if (!parent) return;
setIsDragging(true);
// parent 요소 (svg element)의 정중앙을 구한다
const { x, y, width, height } = parent.getBoundingClientRect();
const left = x + width / 2 - 130;
const top = y + height / 2 - 130;
const mouseMoveHandler = (e: MouseEvent) => {
onChange && onChange(idx, calcAnglePercent(e.x - left, e.y - top));
};
const mouseUpHandler = () => {
setIsDragging(false);
document.removeEventListener('mouseup', mouseUpHandler);
document.removeEventListener('mousemove', mouseMoveHandler);
};
document.addEventListener('mouseup', mouseUpHandler);
document.addEventListener('mousemove', mouseMoveHandler);
};
return (
<g
transform={`rotate(${value * 3.6})`}
className={cx('knob', isDragging && 'dragging')}
onMouseDown={handleMouseDown}
>
<circle fill={color} className={cx('border')} cx={130} cy={16} r={16} />
<circle fill={'white'} cx={130} cy={16} r={13} />
</g>
);
};
도넛차트의 값 변경 핸들러
const handleChange = (idx: number, newVal: number) => {
const newArr = [...lastValues];
newArr[idx] = newVal <= 0 ? newVal + 100 : newVal;
const newValues = newArr.map((v, i, arr) => (i ? v - arr[i - 1] : v));
// 1 미만의 값이 있을 경우 무효처리
if (newValues.some((v) => v < 1)) {
return;
}
onChange && onChange(newValues);
};
lastValues
의 값들은 누적값이기 때문에 원래 값을 추출할 필요가 있다. (추출한 값: newValues
)마지막 손잡이(컴포넌트가 처음 렌더링 되었을 때 12시에 있는 손잡이)는 어느 방향으로 조작하든 문제가 생긴다.
[30, 60, 100]
[30, 60, 1]
[30, 60, 99]
마지막 손잡이는 도넛차트 전체를 회전시키는 동시에 나머지 값을 전부 조절함으로써, 해당 손잡이만 회전한 것처럼 보이게 예외처리를 하였다.
[30, 60, 100], 0
[29, 59, 100], 1
- 나머지 값은 1씩 감소, 회전은 1 증가[31, 61, 100], 99
- 나머지 값은 1씩 증가, 회전은 1 감소const [lastValues, setLastValues] = useState([30, 60, 100]); // 각 값들의 손잡이 위치(즉, 누적된 값)
const rotation = useRef(0); // 도넛차트 전체 회전값
...
const handleChange = (idx: number, val: number) => {
// 맨 마지막 손잡이를 조작할 때에는 도넛차트 전체를 회전시키는 동시에
// 나머지 값들을 조절하여 원래 조작한 손잡이만 움직인 것처럼 보이게 한다
if (idx === values.length - 1) {
const newRotation = val % 100;
const diff = newRotation - rotation.current;
const newArr = [...lastValues];
for (let i = 0; i < lastValues.length - 1; i++) {
newArr[i] -= diff;
// 음수이거나, 100을 초과했을 때는 0 ~ 100 사이의 값으로 맞춰준다
newArr[i] += newArr[i] < 0 ? 100 : newArr[i] >= 100 ? -100 : 0;
}
const newValues = newArr.map((v, i, arr) => (i ? v - arr[i - 1] : v));
// 1 미만의 값이 있을 경우 무효처리
if (newValues.some((v) => v < 1)) {
return;
}
// lastValues 배열에 새 값 할당
// setLastValue를 통해 새 배열 객체를 할당하지 않음으로써
// 불필요한 렌더를 제거한다
lastValues.forEach((v, i, arr) => {
arr[i] = newArr[i];
});
// onChange함수 실행 및 새 rotation값 적용
// onChange 발동 후 새 value가 props로 넘어오면서 밑에 있는 useEffect가 발동된다.
onChange && onChange(newValues);
rotation.current = newRotation;
return;
}
...
}
최종적으로 도넛차트 전체에 대한 회전값을 컴포넌트에 최종적으로 적용시켜 준다.
const DonutChart = function DonutChart({ className, values, onChange }: Props) {
...
const handleChange = (idx: number, val: number) => {
...
// 마우스의 각도에서 전체 회전값을 빼줌으로써 상대적인 각도를 구한다
const newVal = val - rotation.current;
const newArr = [...lastValues];
newArr[idx] = newVal <= 0 ? newVal + 100 : newVal;
const newValues = newArr.map((v, i, arr) => (i ? v - arr[i - 1] : v));
// 1 미만의 값이 있을 경우 무효처리
if (newValues.some((v) => v < 1)) {
return;
}
onChange && onChange(newValues);
};
...
return (
<div className={classNames(cx('chart-wrap'), className)}>
<svg viewBox={'0 0 260 260'} width={260} height={260} transform={`rotate(${rotation.current * 3.6})`}>
{pies.map((v, i) => (
<path
className={cx('pie')}
key={i}
fill={colors[i]}
d={v}
transform={`rotate(${(lastValues[i - 1] ?? 0) * 3.6})`}
/>
))}
<circle fill={'white'} cx={'50%'} cy={'50%'} r={98} />
</svg>
<svg className={cx('knobs')} viewBox={'0 0 260 260'} width={260} height={260}>
{lastValues.map((v, i) => (
<DonutChartKnob key={i} idx={i} value={v + rotation.current} color={colors[i]} onChange={handleChange} />
))}
</svg>
</div>
);
};
export default DonutChart;