
현재 차트는 svg path command를 통해 그려냈다. [M x y] / [L x y]를 통해 그렸는데, 직선을 그리는 커맨드로, 값이 급격하게 바뀌는 부분에서는 바늘처럼 뾰족하게 그려지는 것을 볼 수 있다. 그럼 곡선을 그리려면 어떻게 해야할까?
이전 글에서 svg line command를 이용해 직선을 그렸다면, 이번에는 svg curve command를 이용해 베지어 커브를 그리고, 그 곡선으로 라인차트를 구성해보기로 한다. 곡선을 그리는 방법은 C(cubic curve)와 Q(quadratic curve) 두 가지가 있다.
- [C (x1 y1) (x2 y2) (x y)]: Cubic curve, (x1 y1)는 곡선의 시작을 위한 제어점이고, (x2 y2)는 곡선의 끝을 위한 제어점입니다. (x y) 좌표로 선을 그리며, 제어점의 위치에 따라 곡선이 그려진다.
- [Q (x1 y1) (x y)]: Quadratic curve, (x1 y1)는 곡선을 위한 제어점으로 (x y) 좌표로 곡선을 그리는 것은 동일하지만, 제어점이 하나이다.
이제 곡선을 어떻게 그리는지는 알았는데, 제어점을 위치를 어디에 두고 어느 정도의 기울기로 그려야 할까?
how to draw smooth svg path 키워드 검색을 통해 아이디어를 찾을 수 있었다.
아주 유용한 포스트의 내용을 짧게 정리하면 다음과 같다.
- n번째 위치에서 앞뒤로 존재하는 직선들의 시작점과 시작점, 끝점과 끝점을 잇는 선을 긋는다.
- n번째 시작점과 끝점에서 위에서 그은 선과 같은 종류를 잇는 선과 평행한 연장선을 긋는다.
- 각각 연장선 위에서 원하는 위치에 제어점을 찍는다.
- 제어점으로 베지어 커브를 그린다.
위 설명을 아래 차트에 그려보면 조금 더 쉽게 이해가 가능하다. 아래 차트에서 index 1 - 2 사이의 선을 곡선으로 만드는 작업을 진행해보자.
초록선은 시작점을 잇는 선, 빨간색은 끝점을 잇는 선이다.
청록색 선은 초록선과 평행하게 시작점에서 그려지는 선이고, 연보라색선은 빨간선과 평행하게 끝점에서 그려지는 선이다.
이제 세번째 단계에서 찍은 두 점을, 3차 베지어 곡선의 제어점으로 사용하면 아래와 같은 선이 그려진다.
위의 방법을 이용해 path 전체에 베지어 커브를 적용하면 다음과 같이 그려진다.
제어점의 위치를 구하기 위해서 맨 처음에 기울기가 필요한 것을 보고, 단순히 y = ax + b 같은 직선의 방정식을 생각하고 기울기를 어떻게 구해야하나 생각했다. 그러나 실제로 필요한 것은 직선의 기울기가 아닌 해당 선이 그려지는 각도가 필요했다.
괜히 포스트의 부제에 And a bit of trigonometry가 있는게 아니었다...
이 문제를 풀기 위해서는 삼각함수가 내가 지금 해결하려고 하는 문제의 어떤 부분을 해결할 수 있는지 찾아보기로 했다.
구글링 결과 두 점 사이의 각도는 역삼각함수인 아크탄젠트(Math.atan2)를 이용해 구할 수 있다.
삼각함수는 각을 입력받아 그 각에 대한 삼각비의 값을 출력하는 함수이니, 삼각함수에 대한 역함수는 값을 입력받아 해당 값에 대한 각도를 출력하는 함수가 된다.
Math.atan()의 결과값은 방향 개념이 상실된 두 점 사이의 각도가 된다고 한다. 우리는 제어점을 그리기 위해 점이 위치할 직선이 그려지는 방향도 중요하기 때문에, Math.atan2()를 사용한다.
위에서 알아낸 정보를 토대로 시작점 앞뒤로 위치한 데이터의 점 위치, 끝점 앞뒤로 위치한 데이터의 점 위치를 이용해 Math.atan2()를 통해 각 점사이의 각도를 구하고, 이를 startTheta와 endTheta로 정의하였다.
...
const startTheta = Math.atan2(
y(array[index + 1]) - y(array[index - 1]),
x(index + 1) - x(index - 1)
);
const endTheta =
Math.atan2(
y(array[index + 2]) - y(array[index]),
x(index + 2) - x(index)
) + Math.PI; // 끝점은 시작점과 반대의 방향으로 그려져야 하므로 Math.PI를 더한다
...
이제 각 제어점이 어떤 각도로 그려져야 되는지는 구했다. 그럼 시작점과 끝점에서 해당 각도로 일정 거리 d만큼 떨어져있는 점의 위치는 어떻게 구해야할까? 이 부분은 삼각함수를 통해 다시 구할 수 있다.
시작점/끝점의 x좌표:
시작점/끝점의 x값+Math.cos(startTheta/endTheta)*d
시작점/끝점의 y좌표:시작점/끝점의 y값+Math.sin(startTheta/endTheta)*d
...
// startDistance, endDistance는 제어점을 얼마나 시작점/끝점으로 부터 떨어트릴 것인지에 대한 값
const startDistance = 10
const endDistance = 10
const startControlPointX = x(index) + Math.cos(startTheta) * startDistance;
const startControlPointY =
y(array[index]) + Math.sin(startTheta) * startDistance;
const endControlPointX = x(index + 1) + Math.cos(endTheta) * endDistance;
const endControlPointY =
y(array[index + 1]) + Math.sin(endTheta) * endDistance;
...
여기까지 진행했다면 이제 선을 그리기 위한 시작점과 끝점, 그리고 시작점/끝점의 제어점들 까지 모두 구할 수 있게 된다.
위에서 예제 차트로 간단하게 설명한 과정을 삼각함수와 역삼각함수를 통해 조금 더 자세히 정리하면 아래의 순서로 진행하면 된다.
- 시작점과 끝점의 앞뒤로 위치하는 점을 잇는 가상의 선을 그리고, 해당 직선의 각도를 역삼각함수를 통해 구한다.
- 시작점과 끝점에서 1번에서 구한 각도로 일정 거리만큼 떨어진 점의 좌표를 삼각함수를 통해 구한다.
- 위에서 구한 점들을 Svg Path 로 곡선을 그릴때 제어점으로 사용하여
Cubic curve를 그린다.
삼각함수를 사용해 두 점 사이의 각도를 구하고, 그 각도를 이용해 베지어 커브의 제어점을 찍는 작업은 아주 재미있었다. 잊고 있었던 삼각함수를 다시 사용하여 무언가 계산하고 계산결과를 통해 무언가 만들어내는 부분이 제일 흥미로웠다. 이 포스트를 쓰며 얻은 지식과 경험을, 추후 팀에서 개발중인 프로덕트에 적용하여 실제로 마켓에 운영중인 프로덕트를 개선하는데 써봐야겠다.
소스코드 전체는 여기에서 확인가능하다! (깃헙에 올라간 소스코드에는 데이터 포인트 / 제어점 위치 / 직선 동시 표시 기능을 넣어 놓았으니 껐다키며 어떻게 제어점이 작용하는지 볼 수 있다.)
참고
- https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths
- https://francoisromain.medium.com/smooth-a-svg-path-with-cubic-bezier-curves-e37b49d46c74
- https://spiralmoon.tistory.com/entry/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%9D%B4%EB%A1%A0-%EB%91%90-%EC%A0%90-%EC%82%AC%EC%9D%B4%EC%9D%98-%EC%A0%88%EB%8C%80%EA%B0%81%EB%8F%84%EB%A5%BC-%EC%9E%AC%EB%8A%94-atan2
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/atan2
- https://omath.tistory.com/94