본문 바로가기

iOS/차트그리기

4. iOS Radar Chart 직접 그리기

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

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

 

File -> New File -> iOS -> Cocoa Touch Class 메뉴를 통해, 다음과 같이 RadarChartView라는 Custom View를 생성합니다.

import UIKit

class RadarChartView: UIView {

    /*
    // Only override draw() if you perform custom drawing.
    // An empty implementation adversely affects performance during animation.
    override func draw(_ rect: CGRect) {
        // Drawing code
    }
    */

}

 

먼저 UIBezierPath를 가지고 오각형을 그려보겠습니다.

override func draw(_ rect: CGRect) {
    let cx = rect.midX
    let cy = rect.midY
    let x = rect.midX
    let y = CGFloat(50)

    UIColor.black.setStroke()
    let path = UIBezierPath()
    path.lineWidth = 1
    var radian = Double(0)
    for _ in 0 ..< 5 {
        // 중심좌표를 기준으로 점(x,y)를 radian만큼 회전시킨 점(x2, y2)을 계산한다.
        let x2 = cos(radian) * Double(x - cx) - sin(radian) * Double(y - cy) + Double(cx)
        let y2 = sin(radian) * Double(x - cx) + cos(radian) * Double(y - cy) + Double(cy)

        let point = CGPoint(x: x2, y: y2)
        if path.isEmpty {
            path.move(to: point)
        } else {
            path.addLine(to: point)
        }

        radian += .pi * 2 / 5 // 360도를 5분할한 각만큼 증가
    }
    path.close()
    path.stroke()
}

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

1. 적당한 곳에 위치한 점 P1의 좌표를 얻습니다.

2. 점 P1을 (360/5)°만큼씩 점 C를 중심으로 하여 시계방향으로 회전시켜가며 P2 ~ P5의 좌표를 얻어 해당 점들을 라인으로 이어줍니다.

3. 이렇게 얻은 path를 그려줍니다.

- path.close()를 호출한 이유는 P5와 P1사이를 잇기 위함입니다.

 

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

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

enum CharacteristicType: String {
    case agility = "민첩성"
    case endurance = "지구력"
    case strength = "근력"
    case lexibility = "유연성"
    case intellect = "지력"
}

class CustomUIBezierPath: UIBezierPath {

    var movePoint: CGPoint?

    override func move(to point: CGPoint) {
        super.move(to: point)
        movePoint = point
    }

}

// 5개의 특성을 갖도록 한다
private let chartTypes = [CharacteristicType.agility, CharacteristicType.endurance, CharacteristicType.strength, CharacteristicType.lexibility, CharacteristicType.intellect]

override func draw(_ rect: CGRect) {
    var radian: Double = 0
    let step = 5 // 데이터 가이드 라인은 5단계로 표시한다
    var stepLinePaths = [CustomUIBezierPath]() // 각 단계별 가이드 라인
    for _ in 0 ..< step {
        stepLinePaths.append(CustomUIBezierPath())
    }
    let heightMaxValue = rect.height / 2 * 0.7 // RadarChartView영역내에 모든 그림이 그려지도록 max value가 그려질 높이
    let heightStep = heightMaxValue / CGFloat(step) // 1단계에 해당하는 높이
    let cx = rect.midX
    let cy = rect.midY
    let x = rect.midX
    let y = rect.midY - heightMaxValue
    chartTypes.forEach { type in
        // 차트 중심으로부터 각 특성 다각형 꼭지점까지 잇는 직선 그리기
        UIColor.lightGray.setStroke()
        let path = UIBezierPath()
        path.lineWidth = 1
        path.move(to: CGPoint(x: cx, y: cy))
        path.addLine(to: transformRotate(radian: radian, x: x, y: y, cx: cx, cy: cy))
        path.stroke()

        // 단계별 가이드 라인 path 설정
        stepLinePaths.enumerated().forEach { index, path in
            let point = transformRotate(radian: radian, x: x, y: y + heightStep * CGFloat(index), cx: cx, cy: cy)
            if path.isEmpty {
                path.move(to: point)
            } else {
                path.addLine(to: point)
            }
        }

        radian += .pi * 2 / Double(chartTypes.count)
    }

    stepLinePaths.enumerated().forEach { index, path in
        // 단계별 가이드 라인 그리기
        UIColor.lightGray.setStroke()
        path.close()
        path.stroke()
    }
}

// 점(x, y)를 특정 좌표(cx, cy)를 중심으로 radian만큼 회전시킨 점의 좌표를 반환
private func transformRotate(radian: Double, x: CGFloat, y: CGFloat, cx: CGFloat, cy: CGFloat) -> CGPoint {
    let x2 = cos(radian) * Double(x - cx) - sin(radian) * Double(y - cy) + Double(cx)
    let y2 = sin(radian) * Double(x - cx) + cos(radian) * Double(y - cy) + Double(cy)

    return CGPoint(x: x2, y: y2)
}

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

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

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

 

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

 

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

 

override func draw(_ rect: CGRect) {
    let font = UIFont.systemFont(ofSize: 8)
    let attrs = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black]

    ......
    chartTypes.forEach { type in
        ......

        // 각 꼭지점 부근에 각 특성 문자열 표시
        let point = transformRotate(radian: radian, x: x, y: y * 0.7, cx: cx, cy: cy)
        let strValue = type.rawValue
        let size = strValue.sizeWithFont(font)
        strValue.draw(with: CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height),
                      options: .usesLineFragmentOrigin,
                      attributes: attrs,
                      context: nil)

        ......

        radian += .pi * 2 / Double(chartTypes.count)
    }

    stepLinePaths.enumerated().forEach { index, path in
        ......

        // 각 단계별 기준값 표시
        if let movePoint = path.movePoint {
            let strValue = "\(100 - 20 * index)"
            let size = strValue.sizeWithFont(font)
            strValue.draw(with: CGRect(x: movePoint.x + 3, y: movePoint.y - size.height / 2, width: size.width, height: size.height),
                          options: .usesLineFragmentOrigin,
                          attributes: attrs,
                          context: nil)
        }
    }
}

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

 

stepLinePaths변수타입이 CustomUIBezierPath클래스 배열로 정의되어 있는것을 확인할수 있는데요, 이는 UIBezierPath.move(to:)를 통해 각 단계별 5각형의 최초 꼭지점 좌표를 저장하기 위함이었습니다.

그래서 이렇게 저장했던 좌표들의 우측에 각 단계별 점수를 표시한 것입니다.

 

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

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

2. 각 특성 문자열이 그려질때 차지하게 될 size를 구합니다.

3. 이후 1번에서 얻은 각 점의 좌표를 중심으로 하는 사각형 영역에 문자열을 그려줍니다.

 

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

radarChartView.dataList = [RadarChartData(type: .agility, value: 60),
                           RadarChartData(type: .endurance, value: 70),
                           RadarChartData(type: .strength, value: 50),
                           RadarChartData(type: .lexibility, value: 90),
                           RadarChartData(type: .intellect, value: 85)
struct RadarChartData {
    let type: CharacteristicType
    let value: Int
}

override func draw(_ rect: CGRect) {
    ......
    let valuePath = UIBezierPath() // 각 특성값을 이어 구성할 다각형 path
    chartTypes.forEach { type in
        ......
        
        // 각 특성별 값에 해당하는 좌표를 구해 다각형 path 구성
        if let value = dataList?.first(where: { $0.type == type })?.value {
            let convValue = heightMaxValue * (CGFloat(value) / 100) // 전달된 값을 차트크기에 맞게 변환
            let point = transformRotate(radian: radian, x: x, y: rect.midY - convValue, cx: cx, cy: cy)
            if valuePath.isEmpty {
                valuePath.move(to: point)
            } else {
                valuePath.addLine(to: point)
            }
        }

        radian += .pi * 2 / Double(chartTypes.count)
    }

    ......
    
    // 각 특성별 값을 다각형으로 표시
    UIColor(red: 1, green: 0, blue: 0, alpha: 0.5).setFill()
    valuePath.close()
    valuePath.fill()
}

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

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

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

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

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

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

 

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

import UIKit

enum CharacteristicType: String {
    case agility = "민첩성"
    case endurance = "지구력"
    case strength = "근력"
    case lexibility = "유연성"
    case intellect = "지력"
}

struct RadarChartData {
    let type: CharacteristicType
    let value: Int
}

class CustomUIBezierPath: UIBezierPath {

    var movePoint: CGPoint?

    override func move(to point: CGPoint) {
        super.move(to: point)
        movePoint = point
    }

}

class RadarChartView: UIView {

    var dataList: [RadarChartData]? {
        didSet {
            setNeedsDisplay()
        }
    }

    private let chartTypes = [CharacteristicType.agility, CharacteristicType.endurance, CharacteristicType.strength, CharacteristicType.lexibility, CharacteristicType.intellect]

    override func draw(_ rect: CGRect) {
        let font = UIFont.systemFont(ofSize: 8)
        let attrs = [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: UIColor.black]

        var radian: Double = 0
        let step = 5 // 데이터 가이드 라인은 5단계로 표시한다
        var stepLinePaths = [CustomUIBezierPath]() // 각 단계별 가이드 라인
        for _ in 0 ..< step {
            stepLinePaths.append(CustomUIBezierPath())
        }
        let heightMaxValue = rect.height / 2 * 0.7 // RadarChartView영역내에 모든 그림이 그려지도록 max value가 그려질 높이
        let heightStep = heightMaxValue / CGFloat(step) // 1단계에 해당하는 높이
        let cx = rect.midX
        let cy = rect.midY
        let x = rect.midX
        let y = rect.midY - heightMaxValue
        let valuePath = UIBezierPath() // 각 특성값을 이어 구성할 다각형 path
        chartTypes.forEach { type in
            // 1. 차트 중심으로부터 각 특성 다각형 꼭지점까지 잇는 직선 그리기
            UIColor.lightGray.setStroke()
            let path = UIBezierPath()
            path.lineWidth = 1
            path.move(to: CGPoint(x: cx, y: cy))
            path.addLine(to: transformRotate(radian: radian, x: x, y: y, cx: cx, cy: cy))
            path.stroke()

            // 2. 각 꼭지점 부근에 각 특성 문자열 표시
            let point = transformRotate(radian: radian, x: x, y: y * 0.7, cx: cx, cy: cy)
            let strValue = type.rawValue
            let size = strValue.sizeWithFont(font)
            strValue.draw(with: CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height),
                          options: .usesLineFragmentOrigin,
                          attributes: attrs,
                          context: nil)

            // 3. 단계별 가이드 라인 path 설정
            stepLinePaths.enumerated().forEach { index, path in
                let point = transformRotate(radian: radian, x: x, y: y + heightStep * CGFloat(index), cx: cx, cy: cy)
                if path.isEmpty {
                    path.move(to: point)
                } else {
                    path.addLine(to: point)
                }
            }

            // 4. 각 특성별 값에 해당하는 좌표를 구해 다각형 path 구성
            if let value = dataList?.first(where: { $0.type == type })?.value {
                let convValue = heightMaxValue * (CGFloat(value) / 100) // 전달된 값을 차트크기에 맞게 변환
                let point = transformRotate(radian: radian, x: x, y: rect.midY - convValue, cx: cx, cy: cy)
                if valuePath.isEmpty {
                    valuePath.move(to: point)
                } else {
                    valuePath.addLine(to: point)
                }
            }

            radian += .pi * 2 / Double(chartTypes.count)
        }

        stepLinePaths.enumerated().forEach { index, path in
            // 5. 단계별 가이드 라인 그리기
            UIColor.lightGray.setStroke()
            path.close()
            path.stroke()

            // 6. 각 단계별 기준값 표시
            if let movePoint = path.movePoint {
                let strValue = "\(100 - 20 * index)"
                let size = strValue.sizeWithFont(font)
                strValue.draw(with: CGRect(x: movePoint.x + 3, y: movePoint.y - size.height / 2, width: size.width, height: size.height),
                              options: .usesLineFragmentOrigin,
                              attributes: attrs,
                              context: nil)
            }
        }

        // 7. 각 특성별 값을 다각형으로 표시
        UIColor(red: 1, green: 0, blue: 0, alpha: 0.5).setFill()
        valuePath.close()
        valuePath.fill()
    }

    // 점(x, y)를 특정 좌표(cx, cy)를 중심으로 radian만큼 회전시킨 점의 좌표를 반환
    private func transformRotate(radian: Double, x: CGFloat, y: CGFloat, cx: CGFloat, cy: CGFloat) -> CGPoint {
        let x2 = cos(radian) * Double(x - cx) - sin(radian) * Double(y - cy) + Double(cx)
        let y2 = sin(radian) * Double(x - cx) + cos(radian) * Double(y - cy) + Double(cy)

        return CGPoint(x: x2, y: y2)
    }

}
반응형

'iOS > 차트그리기' 카테고리의 다른 글

5. iOS Radar Chart에 Animation 적용하기  (0) 2022.07.05
3. iOS Pie Chart 직접 그리기  (0) 2022.06.15
2. iOS Bar Chart 직접 그리기  (0) 2022.06.14
1. iOS Line chart 직접 그리기  (0) 2022.06.14