오늘은 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
}
'Android > 차트그리기' 카테고리의 다른 글
5. Android Radar Chart에 Animation 적용하기 (0) | 2022.06.30 |
---|---|
3. Android Pie Chart 직접 그리기 (0) | 2022.06.27 |
2. Android Bar Chart 직접 그리기 (0) | 2022.06.21 |
1. Android Line Chart 직접 그리기 (0) | 2022.06.17 |