9 minute read

차트는 사용자에게 복잡한 정보를 효과적으로 전달하는 핵심적인 요소입니다. 특히 여러 차원의 데이터를 비교 분석하여 UI에 표시할 경우 차트의 장점이 더욱 부각되게 됩니다. 일반적으로 Android 환경에서 차트 구현은 다른 UI 컴포넌트에 비해 높은 난이도를 가지고 있어 시간 비용의 절약을 위해 외부 라이브러리에 의존하는 경우가 많습니다. 하지만 차트를 구현하는 접근법을 이해하고 있다면 프로젝트의 특정 요구사항에 맞춰 차트를 신속하게 개발할 수 있을 뿐만 아니라 외부 라이브러리에 종속되지 않고 커스터마이징된 다양한 시각화 요소를 만들어 낼 수 있을 것입니다.

이번 포스트는 Android Compose를 바탕으로 차트 중에서 흔히 Radar 차트라고 불리는 다각형 차트를 제작하는 접근법과 이에 대한 샘플 차트를 직접 제작하는 과정에 관해 다루어보겠습니다.

사전지식

Radar 차트를 개발하기 위해서는 개발의 접근 방법과 개발할 도구에 대한 이해가 필요합니다. 아래에서부터는 간략하게 사전 지식을 정리하고 진행하는 방향으로 나아가겠습니다.

각도법과 호도법

각도를 표현하는 방법은 각도법(degree)과 호도법(radian)이 존재합니다. 각도법은 우리가 흔하게 사용하는 각도를 재는 방법으로 원을 360등분하여 한 등분을 1∘ 라고 정의합니다. 대표적으로 직각은 90∘ 평각은 180∘ 전각은 360∘를 의미합니다. 이때 단위 기호는 ∘를 사용합니다.

반면, 호도법은 호의 길이로 각도를 재는 방법으로 어느 평면 원 위의 점이 원점을 중심으로 반지름의 길이만큼 한 방향으로 움직였을 때 반지름의 길이와 같은 길이를 갖는 호에 대응하는 중심각의 크기를 1 라디안 이라고 합니다. 정리해서 말하면 원의 반지름의 길이와 호의 길이가 동일할 때 이때 중심각을 1 라디안 이라고 정의합니다. 대표적으로 직각은 π/2 라디안이고 평각은 π 라디안 한 바퀴 전체인 전각은 2π 라디안를 의미합니다.

원의 둘레 공식은 2πr 이며, 다른 말로 호의 길이가 2πr 임을 의미합니다. 이는 원의 반지름이 r일때 2πr 만큼의 호를 가지고 있는 것이기 때문에 2π 라디안으로 표현할 수 있습니다. 이 공식을 연계할 경우 평각은 π 라디안이 되고, 직각은 π/2 라디안으로 표현됩니다.

각도법과 호도법의 단위를 사용하여 비교하자면 아래와 같은 공식이 성립되게됩니다.

\[90∘ =\frac{\pi}{2} rad\]

삼각함수

삼각함수는 각도와 직각 삼각형 변의 길이 사이의 관계를 나타내는 함수를 의미합니다. 일반적으로 직각삼각형의 비를 바탕으로 삼각함수를 정의하지만, 단위원이라는 개념을 사용해서도 삼각함수를 정의할 수 있습니다. 여기서 단위원이란, 중심이 원점(0,0)이고 반지름이 1인 원을 의미합니다. 아래에서부터는 단위원을 사용한 삼각함수의 정의를 정리하겠습니다.

삼각함수를 정의하기 위해 먼저 단위원(중심이 원점이고 반지름이 1인원)위에 특정 점 P(x,y)가 있다고 가정합니다. 그리고 P와 원점을 잇는 선과 x축의 양의 방향과 이루는 각을 θ라고 가정합니다. P(x,y)와 θ는 단지 삼각함수를 정의하기 위한 도구라고 생각하시면 됩니다.

이때 첫번째 삼각함수인 사인(Sine,sinθ)함수는 점 P의 y좌표를 의미합니다. 실제로 아래처럼 직각삼각형의 정의를 연계하여 표현하면 두 공식 전부 동일한 결과를 내포하고 있다는 것을 알 수 있습니다. 빗변의 길이가 1인 이유는 빗변이 단위원의 반지름과 동일하기 때문입니다.

\[sinθ = \frac{대변의 길이}{빗변의 길이} = \frac{y}{1} = y\]

두번째 코사인(Cosine, cosθ) 함수는 점 P의 x좌표를 의미합니다. 직각삼각형에서의 코사인 정의는 밑변의 길이 / 빗변의 길이 이므로 아래 공식처럼 동일하게 연결됨을 확인할 수 있습니다.

\[cosθ = \frac{밑변의 길이}{빗변의 길이} = \frac{x}{1} = x\]

해당 공식을 이제 변형해보겠습니다. 앞서 설명한 sinθ = 대변의 길이/빗변의 길이 를 기준으로 빗변의 길이를 좌항으로 넘겨준다면 아래와 같은 공식이 나타나게됩니다.

\[sinθ * 빗변의 길이 = 대변의 길이\]

이때 빗변은 길이는 반지름을 의미하며, 대변의 길이는 원점으로 부터 y좌표까지 얼마나 떨어져 있음을 의미합니다. 즉 우리는 반지름과 sinθ를 알고 있다면, 해당 포인트의 y좌표가 어느지점에 있는지 알 수 있습니다. x 좌표도 마찬가지로 cosθ 공식을 변형한다면, 반지름과 cosθ를 통해 x좌표가 어느지점에 있는지 알 수 있게 됩니다.

Canvas

Android에서 직접 그림을 그리는 도구로 텍스트, 이미지, 선, 도형 등 다양한 그래픽 요소를 그릴 수 있게 해주는 인터페이스입니다. 이름이 Canvas인 것처럼 실제 세계에서의 그림을 그릴수 있게 도와주는 캔버스와 유사한 목적과 기능을 가지고 있습니다. 기존에는 android.graphics 패키지에 존재하는 Canvas 클래스를 통해 그림을 그렸지만, Android Compose에서는 androidx.compose.ui.graphics.Canvas 인터페이스를 활용하여 직접적으로 그래픽 엔진인 Skia와 통신하여 픽셀 값을 얻어내고 렌더링을 수행합니다. DrawScpoe는 해당 인터페이스를 손쉽게 사용할 수 있도록 그리기 메서드들을 제공합니다. 추가적으로 DrawScope 타입의 람다 리시버를 제공하여 DrawScope 메서드들을 사용하기 쉽도록 구현된 androidx.compose.foundation.Canvas 가 존재합니다.

아래는 androidx.compose.foundation.Canvas 의 정의입니다.

@Composable
fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
    Spacer(modifier.drawBehind(onDraw))

접근법

구현을 위해 가장 먼저 생각해야 봐야할 요소는 다각형을 어떻게 그릴 것인가입니다. 다각형의 형태는 각 꼭짓점이 특정 포인트에 존재하며 각 꼭짓점 사이에 선이 그어진 형태로 이루어져 있습니다. 육각형을 예시로 든다면, 육각형은 최상단에 2개 좌우측에 각각 하나 그리고 최하단 2개의 꼭짓점으로 이루어져있으며, 각 지점간 선이 연결되어 있습니다.

이제 육각형에 외접원을 그립니다. 외접원은 다각형의 모든 꼭짓점을 지나는 원을 의미합니다. 이때 정육각형의 모든 꼭짓점은 외접원의 둘레 위에 있으며, 정육각형의 모든 변의 길이는 정다각형의 정의에 따라 동일합니다.

앞서 원 한바퀴는 2π 라디안을 의미한다고 설명하였습니다. 그리고 정육각형에서 원점을 중심으로 각 꼭짓점까지 선분을 그으면 6개의 합동(동일한) 이등변 삼각형이 만들어집니다. 합동인 이등변삼각형들은 각자 동일한 영역을 차지하기 때문에 중심에 있는 각 또한 마찬가지로 2π라디안을 6개의 동일한 부분으로 균등하게 나눈 것과 동일합니다. 따라서 정육각형 내부에 중심각은 π/3 라디안을 의미합니다. 이 공식을 모든 다각형에 대해 일반화 한다면 아래와 같이 정의됩니다.

\[중심각 = \frac{2π}{n}\]

이제 하나의 정육각형 내 하나의 꼭짓점에 대한 좌표를 구해보겠습니다. 우리는 중심각의 크기를 알고 있기 때문에, 앞서 정리한 공식을 바탕으로 P(x,y) 좌표에 접근할 수 있게 되었습니다. 다시 한번 공식을 정리하자면, sinθ * r = 대변의 길이(y 좌표)cosθ * r = 밑변의 길이(x 좌표) 입니다.

그 다음 꼭짓점의 좌표를 접근하는 방식은 중심각의 크기를 누적시키면 됩니다. 모든 선분에 대한 중심각의 크기는 동일하기 때문입니다. 즉, 두번째 각의 크기는 2π/3가 됩니다. 이어서 세번째 각에 크기는 3π/3이 됩니다. 이 방식을 일반화 시킨다면 각 꼭짓점 별 모든 좌표의 위치를 알 수 있게 됩니다.

마지막으로 구해야 할 값은 반지름이 r입니다. r은 무엇을 의미할까요? 우리가 지금까지 왔던 여정의 목적은 차트을 그리는 것이였습니다. 여기서 r의 의미는 차트 데이터의 값를 의미합니다. 예를 들어, 차트 데이터의 값이 50이라면 P(cos(π/3) * 50, sin(π/3) * 50) 좌표에 점을 찍을 수 있게 되는 것입니다. 만약에 다음 차트의 데이터 값이 70이라면 어떨까요? Q(cos(2π/3) * 70, sin(2π/3) * 70) 가 됩니다. 두번째 각의 크기는 2π/3이기 때문입니다.

이렇게 모든 방식으로 꼭짓점을 찍고 선분을 잇게 된다면, 다각형 차트를 그릴 수 있게됩니다.

정육각형을 예시로 들었지만, 정칠각형, 정팔각형,… 등 모든 정다각형은 해당 접근법을 사용하여 구현해낼 수 있습니다. 나머지의 차트 스타일 및 위치와 같은 부수적인 작업들은 Android Compose가 제공하는 기능들을 사용하여 완전한 차트를 구현해 낼 수 있을 것입니다.

구현

캔버스 생성하기

다각형 차트를 구현하기 위해서는 가장 먼저 그림을 그리기 위한 영역을 설정해주어야 합니다. 그리기 위한 영역은 앞서 소개한 androidx.compose.foundation.Canvas 를 사용할 것입니다. 해당 컴포저블은 DrawScope 를 제공하여 그리기 메서드를 사용할 수 있도록 지원해주기 때문에 간편하기 차트를 그리기 위한 준비를 할 수 있습니다. 그리기 영역을 정의한 후에는 추가적으로 해당 그리기 영역을 가로 스크린 절반사이즈의 정사각형 비로 배치하기 위해서 Modifier를 통해 선언하였습니다.

Canvas(
    modifier = modifier
        .fillMaxWidth(0.5f)
        .aspectRatio(1f)
) {
 // 이곳에 그림을 그릴 예정입니다.
}

배경 그리기

이제 DrawScope 가 제공된 블록 내에서 가장 먼저 다각형 차트의 배경을 그리겠습니다. 배경은 동일한 반지름을 가진 다각형의 지점 마다 포인트들을 생성하고 그 점을 이어서 구현하면 됩니다. 우선 아래와 같이 각 지점 마다의 중심각을 구해줄 단위를 생성해줍니다.

val angleStep = (2 * Math.PI) / sides //sides는 다각형의 변을 의미합니다.

그 후 선의 차트의 시작점이 될 부분을 선언합니다. 앞서 다각형의 특정 포인트를 구할 때는 원점을 기준으로 하여 구했지만, Android Canvas내 특정 포인트의 위치를 구할때는 이전과는 다른 접근방식을 사용하여야합니다.

먼저 Canvas에 그리기 시작하는 기준점은 항상 왼쪽 상단입니다. 이에 따라 x축은 오른쪽으로 갈수록 증가하고 y축은 아래쪽으로 갈수록 증가합니다. 일반적인 수학의 개념에서는 y축이 위로 올라갈수록 증가하는것과 다른 개념입니다. 해당 기본개념을 바탕에서 원점의 위치를 생각한다면, 원점의 위치는 캔버스의 총 너비와 높이의 젋반 값(width/2 또는 height/2)이 됩니다.(Canvas에서는 해당 위치를 바로 접근할 수 있도록 center.x와 center.y 필드를 제공하고 있습니다.) 추가적으로 반지름의 길이는 앞서 캔버스의 크기를 정사각형으로 정의하였기 때문에, center.x 또는 center.y로 표시할 수 있습니다.

이제 앞서 설명한 내용을 정리해서 공식으로 풀어 쓸 경우 x 좌표는 center.x + (center.x * cos(angleStep * 0)) y좌표는 center.y - (center.y * sin(anglestep * 0) 이 됩니다. 0을 곱한 이유는 중심각의 시작점을 0 라디안에서 시작하도록 하기 위해서입니다. 0 라디안에서 부터 시작하여 그 뒤로 3π/5 구간 까지의 총 6개의 포인트를 찍습니다. 구현된 코드는 아래와 같습니다.

val angleStep = (2 * Math.PI) / sides

val path = Path().apply {
    val firstPointX =
        (center.x + (center.x * cos(angleStep * 0))).toFloat()
    val firstPointY =
        (center.y - (center.y * sin(angleStep * 0))).toFloat()
    moveTo(firstPointX, firstPointY)
}

Path 인터페이스의 moveTo() 메서드를 사용할 경우 그리기 포인트를 이동시킬 수 있습니다. 현재 코드에서는 시작점을 정의한 후 moveTo()를 사용하여 시작점의 위치를 이동시켰습니다.

이제 나머지 다섯 포인트에 대한 좌표를 Path 인터페이스의 lineTo()메서드를 사용하여 연결할 수 있습니다.

val sides = 6

val angleStep = (2 * Math.PI) / sides

val path = Path().apply {
    val firstPointX =
        (center.x + (center.x * cos(angleStep * 0))).toFloat()
    val firstPointY =
        (center.y - (center.y * sin(angleStep * 0))).toFloat()
    moveTo(firstPointX, firstPointY)

    for (i in 1 until sides) {
        val targetPointX =
            (center.x + (center.x * cos(angleStep * i))).toFloat()
        val targetPointY =
            (center.y - (center.y * sin(angleStep * i))).toFloat()
        lineTo(targetPointX, targetPointY)
    }
    close()
}

지금까지는 경로에 대한 정의만을 하였기 때문에 실제로 해당 경로를 그리는 작업을 수행하여야만 합니다. DrawScope에서는 drawPath()라는 제공하며, 해당 메서드를 수행하여 그리기를 진행할 수 있습니다. 그리기 작업까지 구현된 코드는 아래와 같습니다.

fun DrawScope.drawBackground(
    sides: Int,
    backgroundColor: Color,
    backgroundBorderColor: Color,
    borderWidth: Float = 4f,
) {
    val angleStep = (2 * Math.PI) / sides

		// 경로 그리기
    val path = Path().apply {
        val firstPointX =
            (center.x + (center.x * cos(angleStep * 0))).toFloat()
        val firstPointY =
            (center.y - (center.y * sin(angleStep * 0))).toFloat()
        moveTo(firstPointX, firstPointY)

        for (i in 1 until sides) {
            val targetPointX =
                (center.x + (center.x * cos(angleStep * i))).toFloat()
            val targetPointY =
                (center.y - (center.y * sin(angleStep * i))).toFloat()
            lineTo(targetPointX, targetPointY)
        }
        close()
    } 
    
    // 배경 내부 칠하기
    drawPath(
        path = path,
        style = Fill,
        color = backgroundColor,

        )
        
    // 배경 외곽선 그리기
    drawPath(
        path = path,
        style = Stroke(
            width = borderWidth,
            cap = StrokeCap.Round,
            join = StrokeJoin.Round
        ),
        color = backgroundBorderColor
    )
}

이때 매개변수를 자유롭게 커스텀 하여 다양한 차트 배경 스타일을 만들어 낼 수 있습니다.

데이터 그리기

데이터를 그리는 방식은 기존 배경그리기 방식과 접근법은 유사하지만, 데이터 별로 정확히 어떤 지점에 포인트를 찍어야하는지를 계산하여야만 합니다. 가정 먼저 가상의 데이터 세트를 정의하겠습니다. 아래 코드는 0 부터 99까지 범위 내에서 sides 개수만큼 데이터를 생성합니다.

 var chartData: List<Int> = List(size = sides) { Random.nextInt(0, 100) }

그리고 차트상에서 데이터를 표기할 최대 범위를 설정 해주어야만 합니다. 최대 범위를 설정하는 방식은 요구사항에 따라 달라질 수 있지만, 해당 포스트에서는 max()를 사용하였습니다.

val maxData = chartData.maxOrNull() ?: 0

앞서 반지름의 길이는 center.x 또는 center.y 임을 언급하였으며, 한편으로 center.x 또는 center.y는 Canvas에서 표현할 수 있는 최대 반지름임을 의미합니다. center.x 또는 center.y를 최대 길이로 잡고 data / maxData 비를 곱하면 차트 내에서 상대적인 포인트의 위치를 표시할 수 있게됩니다. 이제 해당 공식과 앞서 차트 배경을 그릴때 사용된 공식을 결합하면 아래와 같은 결과가 만들어집니다. 아래 공식은 첫번째 차트 데이터의 위치를 표기하는 공식입니다.

val firstPointX =
    center.x + ((chartData[0].toFloat() / maxData) * center.x * cos(angleStep * 0))
val firstPointY =
    center.y - ((chartData[0].toFloat() / maxData) * center.y * sin(angleStep * 0))

이어서 동일한 과정으로 모든 차트 데이터를 표시합니다

fun DrawScope.draw(
    chartData: List<Int>,
    maxData: Int,
    sides: Int,
    color: Color,
    borderColor: Color,
    borderWidth: Float = 4f
) {
    val angleStep = (2 * PI / sides).toFloat()

    val path = Path().apply {
        val firstPointX =
            center.x + ((chartData[0].toFloat() / maxData) * center.x * cos(angleStep * 0))
        val firstPointY =
            center.y - ((chartData[0].toFloat() / maxData) * center.y * sin(angleStep * 0))
        moveTo(firstPointX, firstPointY)

        for (i in 1 until sides) {
            val targetPointX =
                center.x + ((chartData[i].toFloat() / maxData) * center.x * cos(angleStep * i))
            val targetPointY =
                center.y - ((chartData[i].toFloat() / maxData) * center.y * sin(angleStep * i))
            lineTo(targetPointX, targetPointY)
        }
        close()
    }

    // 데이터 내부 그리기
    drawPath(
        path = path,
        style = Fill,
        color = color
    )

    // 데이터 외곽선 그리기
    drawPath(
        path = path,
        style = Stroke(
            width = borderWidth,
            cap = StrokeCap.Round,
            join = StrokeJoin.Round
        ),
        color = borderColor
    )
}

노란빛 계열의 컬러를 사용하여 커스터마이징 하여 아래와 같은 결과를 얻어냈습니다.

결합

이제 앞서 완성한 코드들을 결합하여 최종적인 차트를 만들 수 있게됩니다. Canvas 컴포저블 내에 앞서 제작한 그리기 함수들을 아래와 같이 포함시킵니다.

@Composable
fun RadarChartView(
    modifier: Modifier = Modifier,
    chartData: List<Int>,
    sides: Int,
    color: Color = Color(0xFFFFA825),
    borderColor: Color = Color(0xFFFFA825),
    borderWidth: Float = 4f,
    backgroundColor: Color = Color(0xFFEEF1F6),
    backgroundBorderColor: Color = Color(0xFFCFCFD9),
    backgroundBorderWidth: Float = 4f
) {
    Canvas(
        modifier = modifier
            .fillMaxWidth(0.5f)
            .aspectRatio(1f)
    ) {
        drawBackground(
            sides = sides,
            backgroundColor = backgroundColor,
            backgroundBorderColor = backgroundBorderColor,
            borderWidth = backgroundBorderWidth
        )
        draw(
            chartData = chartData,
            maxData = chartData.maxOrNull() ?: 0,
            sides = sides,
            color = color,
            borderColor = borderColor,
            borderWidth = borderWidth
        )
    }
}


해당 과정을 끝으로 차트를 완성시켰지만, 해당 차트에 애니메이션을 넣거나 또는 라벨과 같은 추가적인 시각적 요소를 넣는 등의 방법으로 더욱 시각적으로 풍부하도록 차트를 개선할 수 있습니다. 또한 앞서 소개한 레이더 차트를 만들어내는 메커니즘을 응용한다면, 차트 데이터를 그리기 시작하는 순서를 변경하거나 레이더 차트의 위치나 방향을 변경할 수 있는 등 다양한 요구사항에 따라 구현할 수 있을것입니다.