조작 가능한 도넛 차트 개발하기

Droomii·2024년 4월 4일
0

프로젝트에서 이런 모양의 조작 가능한 도넛 차트 개발을 요청받았다.

요구사항:

  1. 도넛차트의 손잡이를 잡고 돌리면 하나는 증가하고 하나는 감소하여 종합지수 100%가 유지되어야 한다.
  2. 밑의 수치를 직접 입력했는데 종합지수 100%가 되지 않을 경우에는 도넛차트에 적용되지 않고, 나중에 100%가 맞춰지면 적용된다.
  3. 각 손잡이는 서로 겹치거나 뛰어넘어선 안 된다 (손잡이 간의 거리가 1 미만으로 떨어지면 안 된다)

핵심 함수:

  • Math.sin, Math.cos
    • 사인, 코사인 함수 - 도넛차트 조각(부채꼴)을 그릴 때에 사용
  • Math.atan2
    • 역탄젠트 함수 - 두 점 사이의 절대각도를 잴 때 사용한다. 마우스의 위치가 도넛차트의 중앙을 기준으로 몇 도 기울어져 있는지 알아낼 수 있다.

부채꼴 그리기

도넛차트의 총합은 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
  • sweep-flag: 호가 꺾이는 방향을 결정한다. 0이면 왼쪽으로 꺾이고, 1이면 오른쪽으로 꺾인다. 개발하는 도넛차트는 12시 방향을 0도로 하며, 시계방향으로 각도를 측정한다. 그러므로 sweep-flag는 1이 된다.
  • large-arc-flag: 겹쳐진 두 원 중에 더 큰 호를 선택하여 그릴 수 있다. 개발하는 도넛차트에서는 값이 50 즉 180도가 넘으면 해당 플래그를 1로 변경하여 더 큰 호를 그릴 수 있게 해야 한다.

각도 기준점의 이동

일반적으로 화면의 좌표체계에서는, x가 증가하면 오른쪽으로, y가 증가하면 아래로 이동한다. 따라서 sin, cos 함수에 들어가는 값이 커질 수록 좌측 하단에서 우측 상단으로 호를 그리며 이동하게 된다.

0~90도까지의 곡선

즉 이대로 사용하게 되면 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);
  • sin, cos 계산을 할 때 처음부터 180도를 더하면 12시를 0도로 잡을 수 있다.
  • 반시계방향을 시계방향으로 바꾸려면 각도를 음수로 변형시키면 된다.
  • 최종적으로 삼각함수에 들어갈 각도의 값은 180 - 각도 가 된다.
  • Math.sin과 Math.cos는 각도가 아닌 radian을 입력받는데, 호를 그리는 함수의 사용이 편리하게 하기 위해 각도를 radian으로 변환하는 로직을 추가로 작성하고, 실제로 함수를 사용할 때에는 각도(deg)를 입력받게 한다.
    • radian이란? - 반지름과 호의 길이가 동일할 때의 각도
    • 1도 = π / 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}`;
}

generatePieSlicePath(45, 100)를 사용하여 그린 호

도넛 조각을 그리는 데에는 성공했는데, 모두 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의 정중앙으로 지정하면, 도넛의 중심을 기준으로 회전시킬 수 있다.

결과물

가운데에 하얀 원을 그려주면 도넛 모양이 완성된다.

<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는 두 가지이다.

  • values - 도합 100이 되는 정수 배열
  • onChange - 값 변경 핸들러
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>
  );
};
  • 입력된 values가 합 100이 되지 않는 경우 도넛차트의 값이 업데이트되지 않는다.
  • 전달받은 values는 누적된 값으로 사용하는게 더 편리하므로, 누적값을 state로 저장한다.
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시 방향이고, 시계방향으로 움직인다.

  • 계산된 값에 90도를 더해주면 12시를 기준으로 한 값이 나온다.

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;
}
  • 각도가 음수일 경우 360도를 더해주면 180도를 초과한 값의 표현이 가능하다.
  • 마우스 좌표에서 반지름의 음수만큼 평행이동 시켜줌으로써 기준좌표를 (0, 0)으로 맞춘다.
  • 각도를 퍼센티지(3.6도 = 1%)로 변환하여 값을 반환한다.

손잡이 컴포넌트 코드:

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]
      • 0도(360도)에서 3.6도로 넘어가면서, 101이여야 하는 값이 1이 되고, 누적값을 역산하면 마지막 값이 음수가 된다.
    • 반시계방향: [30, 60, 99]
      • 손잡이의 위치는 문제가 없지만, 값의 총합이 100이 되지 않게 된다. 도넛차트의 1%가 비게 된다.

마지막 손잡이는 도넛차트 전체를 회전시키는 동시에 나머지 값을 전부 조절함으로써, 해당 손잡이만 회전한 것처럼 보이게 예외처리를 하였다.

  • 기존 손잡이 위치 및 회전: [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;
profile
What, How 이전에 Why를 고민하는 개발자입니다.

0개의 댓글

관련 채용 정보