본문 바로가기

Android/차트그리기

4. Android Radar Chart 직접 그리기

오늘은 Radar Chart(방사형 차트)를 그려볼까 합니다.

게임에서 캐릭터들의 능력치를 표시할때 흔히 보던 차트입니다.

 

먼저 다음과 같이 RadarChartView라는 Custom View를 생성하고, onDraw함수를 override해 줍니다.
이 onDraw() 함수내에서 원하는 그림을 그리면 화면에 나타나게 됩니다.

class RadarChartView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
    }

}

 

우선, 아래와 같이 drawLine() 함수를 이용하여 오각형을 그려보겠습니다.

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return

    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 2f
    val cx = width / 2f
    val cy = height / 2f
    var startX = width / 2f
    var startY = height * 0.2f
    val radian = PI.toFloat() * 2 / 5 // 360도를 5분할한 각만큼 회전시키 위해
    for (i in 0..4) {
        // 중심좌표를 기준으로 점(startX,startY)를 radian만큼씩 회전시킨 점(stopX, stopY)을 계산한다.
        val stopX = cos (radian) * (startX - cx) - sin(radian) * (startY - cy) + cx
        val stopY = sin (radian) * (startX - cx) + cos(radian) * (startY - cy) + cy

        canvas.drawLine(startX, startY, stopX, stopY, paint)

        startX = stopX
        startY = stopY
    }
}

이렇게 위의 그림과 같이 오각형이 잘 그려졌는데요, 이 오각형을 구성하는 개념은 다음과 같습니다.

1. 적당한 곳에 위치한 점 P1(startX, startY)의 좌표를 얻습니다.

2. 점 P1을 (360/5)°만큼 점 C(cx, cy)를 중심으로 하여 시계방향으로 회전시킨 점 P2를 얻은 후 P1과 P2를 라인으로 그려줍니다.

다시 P2를 (360/5)°만큼 점 C(cx, cy)를 중심으로 하여 회전시킨 점 P3를 얻어 P2와 P3를 라인으로 그려줍니다.

이를 오각형이 만들어질때까지 반복 처리합니다.

 

이런식으로 다각형을 반복해서 그려주면 맨위에 보신 거미줄 모양의 방사형 차트를 그릴수가 있습니다.

자 이제 위에서 본 코드를 활용하여 거미줄 모양을 그려보도록 하겠습니다.

enum class CharacteristicType(val value: String) {
    agility("민첩성"),
    endurance("지구력"),
    strength("근력"),
    lexibility("유연성"),
    intellect("지력")
}

// 5개의 특성을 갖도록 한다
private var chartTypes = arrayListOf(
        CharacteristicType.agility,
        CharacteristicType.endurance,
        CharacteristicType.strength,
        CharacteristicType.lexibility,
        CharacteristicType.intellect
    )

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return

    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 1f
    val radian = PI.toFloat() * 2 / 5 // 360도를 5분할한 각만큼 회전시키 위해
    val step = 5 // 데이터 가이드 라인은 5단계로 표시한다
    val heightMaxValue = height / 2 * 0.7f // RadarChartView영역내에 모든 그림이 그려지도록 max value가 그려질 높이
    val heightStep = heightMaxValue / step // 1단계에 해당하는 높이
    val cx = width / 2f
    val cy = height / 2f
    // 1. 단계별 가이드라인(5각형) 그리기
    for (i in 0..step) {
        var startX = cx
        var startY = (cy - heightMaxValue) + heightStep * i
        repeat(chartTypes.size) {
            // 중심좌표를 기준으로 점(startX,startY)를 radian만큼씩 회전시킨 점(stopX, stopY)을 계산한다.
            val stopPoint = transformRotate(radian, startX, startY, cx, cy)
            canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, paint)

            startX = stopPoint.x
            startY = stopPoint.y
        }
    }

    // 2. 중심으로부터 5각형의 각 꼭지점까지 잇는 라인 그리기
    var startX = cx
    var startY = cy - heightMaxValue
    repeat(chartTypes.size) {
        val stopPoint = transformRotate(radian, startX, startY, cx, cy)
        canvas.drawLine(cx, cy, stopPoint.x, stopPoint.y, paint)

        startX = stopPoint.x
        startY = stopPoint.y
    }
}

// 점(x, y)를 특정 좌표(cx, cy)를 중심으로 radian만큼 회전시킨 점의 좌표를 반환
private fun transformRotate(radian: Float, x: Float, y: Float, cx: Float, cy: Float): PointF {
    val stopX = cos(radian) * (x - cx) - sin(radian) * (y - cy) + cx
    val stopY = sin(radian) * (x - cx) + cos(radian) * (y - cy) + cy

    return PointF(stopX, stopY)
}

거미줄 모양이 예쁘게 잘 표시되고 있네요.

특정 캐릭터의 능력치가 0 ~ 100 까지의 값을 갖는다고 할때,  이를 5분할하여 가이드라인을 표시하도록 하였습니다.

P1이 위치하는 5각형은 100점을 의미하며, 가장 가운데 작은 5각형은 20점을 의미하게 됩니다.

 

그래서 P1의 y값을 heightStep씩 더해가며 각 5각형의 최초 꼭지점을 구한후, 이 꼭지점들을 시계방향으로 회전시켜가며 각 꼭지점들의 좌표를 구해 이은 것입니다.

 

이어서, 각 단계별 값이 어떻게 되는지와 각 꼭지점이 어떤 특성을 나타내는지 알수 있도록 문자열 정보를 추가로 그려봅니다.

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return

    ......
    // 1. 단계별 가이드라인(5각형) 그리기
    for (i in 0..step) {
        ......

        // 각 단계별 기준값 표시
        if (i < step) {
            val strValue = "${100 - 20 * i}"
            textPaint.textAlign = Paint.Align.LEFT
            canvas.drawText(
                strValue,
                startX + 10,
                textPaint.fontMetrics.getBaseLine(startY),
                textPaint
            )
        }
    }

    // 2. 중심으로부터 5각형의 각 꼭지점까지 잇는 라인 그리기
    ......

    // 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
    textPaint.textAlign = Paint.Align.CENTER
    startX = cx
    startY = (cy - heightMaxValue) * 0.7f
    var r = 0f
    chartTypes.forEach { type ->
        val point = transformRotate(r, startX, startY, cx, cy)
        canvas.drawText(
            type.value,
            point.x,
            textPaint.fontMetrics.getBaseLine(point.y),
            textPaint
        )

        r += radian
    }
}

// y좌표가 중심이 오도록 문자열을 그릴수 있도록하는 baseline좌표를 반환
fun Paint.FontMetrics.getBaseLine(y: Float): Float {
    val halfTextAreaHeight = (bottom - top) / 2
    return y - halfTextAreaHeight - top
}

이렇게, 각 단계별 점수가 어떻게 되는지와 꼭지점이 어떤 특성을 의미하는지 쉽게 알수 있게 되었습니다.

 

각 단계별 점수는 위에서 본 P1과 중심을 잇는 직선을 따라, P1의 y좌표값에 heightStep만큼 더해가며 점수를 표시한 것입니다.

이 때, 문자열이 좌측정렬이 될수 있도록 textPaint의 textAlign을 LEFT로 설정하였고, 각 y좌표에 문자열의 세로 중심이 위치하도록 baseline을 계산하였습니다.

그리고, 위 코드를 보시면 특정 y좌표가 문자열의 중심이 되는 baseline을 계산하기 위해 FontMetrics의 extension 구현부를 추가하였는데요, 이 계산원리는 다음 글을 참고 부탁 드립니다.

2022.06.27 - [Android/차트그리기] - 3. Android Pie Chart 직접 그리기

 

그리고, 꼭지점 부근에 특성 문자열을 그리는 원리도 간단합니다.

1. 위에서 본 P1보다 약간 위에 위치하는 점의 좌표(x, y * 0.7)를 구한후, 이 점을 시계방향으로 회전시켜가며 각 꼭지점 부근에 위치한 점의 좌표들을 구합니다.

2. 각 특성 문자열이 해당 좌표의 중심에 그려질수 있도록 textPaint의 textAlign을 CENTER로 설정하고, baseline위치를 계산하여 문자열을 그리도록 하였습니다.

 

마지막으로, 아래와 같이 실제 캐릭터의 각 능력치를 전달하여 차트에 표시해 보는 일만 남았습니다.

chartView.setDataList(
            arrayListOf(
                RadarChartData(CharacteristicType.agility, 60),
                RadarChartData(CharacteristicType.endurance, 70),
                RadarChartData(CharacteristicType.strength, 50),
                RadarChartData(CharacteristicType.lexibility, 90),
                RadarChartData(CharacteristicType.intellect, 85)
            )
        )
private var path = Path()

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return

    ......

    // 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
    path.reset()
    chartTypes.forEach { type ->
        ......

        // 전달된 데이터를 표시하는 path 계산
        dataList?.firstOrNull { it.type == type }?.value?.let { value ->
            val conValue = heightMaxValue * value / 100 // 차트크기에 맞게 변환
            val valuePoint = transformRotate(r, startX, cy - conValue, cx, cy)
            if (path.isEmpty) {
                path.moveTo(valuePoint.x, valuePoint.y)
            } else {
                path.lineTo(valuePoint.x, valuePoint.y)
            }
        }

        r += radian
    }

    // 4. 전달된 데에터를 표시하기
    path.close()
    paint.color = 0x7FFF0000
    paint.style = Paint.Style.FILL
    canvas.drawPath(path, paint)
}

능력치에 해당하는 다각형을 그리는 개념은 다음과 같습니다.

1. C·P1을 지나는 직선위에 있도록  각 특성별 능력치의 y좌표를 먼저 계산합니다.

- conValue가 heightMaxValue를 기준으로 환산된 능력치 값을 나타내는 값입니다.

- 이를 좌표계로 바꾸려면 점 C의 y좌표값(cy)에서 convValue를 빼주면 되죠.

2. 이렇게 계산된 각 특성별 능력치 좌표를 각 특성에 맞는 각도로 회전시켜 다각형path를 구성합니다.

3. 이제 2번에서 구성된 path를 그려줍니다.

 

다음은 이를 그리기 위한 전체 코드입니다.

enum class CharacteristicType(val value: String) {
    agility("민첩성"),
    endurance("지구력"),
    strength("근력"),
    lexibility("유연성"),
    intellect("지력")
}

data class RadarChartData(
    val type: CharacteristicType,
    val value: Int
)

class RadarChartView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    private var dataList: ArrayList<RadarChartData>? = null

    // 5개의 특성을 갖도록 한다
    private var chartTypes = arrayListOf(
        CharacteristicType.agility,
        CharacteristicType.endurance,
        CharacteristicType.strength,
        CharacteristicType.lexibility,
        CharacteristicType.intellect
    )

    private val paint = Paint().apply {
        isAntiAlias = true
    }
    private val textPaint = TextPaint().apply {
        textSize = 28f
        textAlign = Paint.Align.CENTER
    }
    private var path = Path()

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas ?: return

        paint.color = Color.BLACK
        paint.style = Paint.Style.STROKE
        paint.strokeWidth = 1f
        val radian = PI.toFloat() * 2 / 5 // 360도를 5분할한 각만큼 회전시키 위해
        val step = 5 // 데이터 가이드 라인은 5단계로 표시한다
        val heightMaxValue = height / 2 * 0.7f // RadarChartView영역내에 모든 그림이 그려지도록 max value가 그려질 높이
        val heightStep = heightMaxValue / step // 1단계에 해당하는 높이
        val cx = width / 2f
        val cy = height / 2f
        // 1. 단계별 가이드라인(5각형) 그리기
        for (i in 0..step) {
            var startX = cx
            var startY = (cy - heightMaxValue) + heightStep * i
            repeat(chartTypes.size) {
                // 중심좌표를 기준으로 점(startX,startY)를 radian만큼씩 회전시킨 점(stopX, stopY)을 계산한다.
                val stopPoint = transformRotate(radian, startX, startY, cx, cy)
                canvas.drawLine(startX, startY, stopPoint.x, stopPoint.y, paint)

                startX = stopPoint.x
                startY = stopPoint.y
            }

            // 각 단계별 기준값 표시
            if (i < step) {
                val strValue = "${100 - 20 * i}"
                textPaint.textAlign = Paint.Align.LEFT
                canvas.drawText(
                    strValue,
                    startX + 10,
                    textPaint.fontMetrics.getBaseLine(startY),
                    textPaint
                )
            }
        }

        // 2. 중심으로부터 5각형의 각 꼭지점까지 잇는 라인 그리기
        var startX = cx
        var startY = cy - heightMaxValue
        repeat(chartTypes.size) {
            val stopPoint = transformRotate(radian, startX, startY, cx, cy)
            canvas.drawLine(cx, cy, stopPoint.x, stopPoint.y, paint)

            startX = stopPoint.x
            startY = stopPoint.y
        }

        // 3. 각 꼭지점 부근에 각 특성 문자열 표시하기
        textPaint.textAlign = Paint.Align.CENTER
        startX = cx
        startY = (cy - heightMaxValue) * 0.7f
        var r = 0f
        path.reset()
        chartTypes.forEach { type ->
            val point = transformRotate(r, startX, startY, cx, cy)
            canvas.drawText(
                type.value,
                point.x,
                textPaint.fontMetrics.getBaseLine(point.y),
                textPaint
            )

            // 전달된 데이터를 표시하는 path 계산
            dataList?.firstOrNull { it.type == type }?.value?.let { value ->
                val conValue = heightMaxValue * value / 100 // 차트크기에 맞게 변환
                val valuePoint = transformRotate(r, startX, cy - conValue, cx, cy)
                if (path.isEmpty) {
                    path.moveTo(valuePoint.x, valuePoint.y)
                } else {
                    path.lineTo(valuePoint.x, valuePoint.y)
                }
            }

            r += radian
        }

        // 4. 전달된 데에터를 표시하기
        path.close()
        paint.color = 0x7FFF0000
        paint.style = Paint.Style.FILL
        canvas.drawPath(path, paint)
    }

    fun setDataList(dataList: ArrayList<RadarChartData>) {
        if (dataList.isEmpty()) {
            return
        }
        this.dataList = dataList
        invalidate()
    }

    // 점(x, y)를 특정 좌표(cx, cy)를 중심으로 radian만큼 회전시킨 점의 좌표를 반환
    private fun transformRotate(radian: Float, x: Float, y: Float, cx: Float, cy: Float): PointF {
        val stopX = cos(radian) * (x - cx) - sin(radian) * (y - cy) + cx
        val stopY = sin(radian) * (x - cx) + cos(radian) * (y - cy) + cy

        return PointF(stopX, stopY)
    }
}

// y좌표가 중심이 오도록 문자열을 그릴수 있도록하는 baseline좌표를 반환
fun Paint.FontMetrics.getBaseLine(y: Float): Float {
    val halfTextAreaHeight = (bottom - top) / 2
    return y - halfTextAreaHeight - top
}
 
반응형