📊

Swift ChartsでGitHubのLanguagesのグラフを作成

2022/11/28に公開

概要

  • 下記のようにGitHubのLanguagesのグラフを、Swift Chartsで再現してみます。
GitHubの表示 Swift Chatrsで作成したもの

GitHub

実装

GitHubの言語別の色の取得

  • GitHubで使われている言語別の色の定義を行います。
  • 今回は下記の設定をおかりしました。
  • GitHubLanuageColorというクラスを作り、GitHubLanguageColor.shared.getColor(withName: name)のように言語名からColorを取得ようにしています。
GitHubLanguageColor.swift
struct GitHubLanguageColor {

    // MARK: - Properties

    static let shared: GitHubLanguageColor = .init()

    private let languages: [Language]

    // MARK: - LifeCycle

    private init() {
        // 定義ファイルの読み込み
        // (省略)
    }

    // MARK: - Public methods

    func getColor(withName name: String?) -> Color? {
        guard let name else {
            return nil
        }

        let language = languages.first { language in
            name == language.name
        }
        
        return language?.color
    }
}

リポジトリで使用されている言語情報

情報の取得方法

https://api.github.com/repos/<user-name>/<repository-name>/languages
  • 今回表示の例としては下記のリポジトリを使っています。
  • よって言語情報を取得するためのURLはhttps://api.github.com/repos/robovm/apple-ios-samples/languagesとなり、下記の情報が取得できます。
    • valueの算出方法はわかりませんが、この値を言語の使用量として扱えそうですね。
{
  "Objective-C": 11414333,
  "C++": 2479029,
  "Swift": 2299854,
  "Objective-C++": 1217905,
  "C": 794349,
  "Metal": 88350,
  "JavaScript": 78308,
  "HTML": 38193,
  "Rebol": 12190,
  "Python": 10621,
  "Ruby": 5325,
  "GLSL": 2584
}

言語情報のstructの作成

  • 上記の情報をjsonとして読み込み、下記のstructとして言語別に情報を保存します。
struct Language: Identifiable {
    let name: String
    let amount: Int
    let percentage: Double
    let color: Color
    
    var titleForLegend: String {
        "\(name) \((percentage * 100).truncate(places: 1))%"
    }
    
    var id: String { name }
}
  • 今回Swift Chartsでは%で扱いたいので、下記のように一回amountの総量を計算して%を算出します。
let sumAmount: Double = json.reduce(0.0) { partialResult, dict in
    let amount = dict.value
    return partialResult + Double(amount)
}
...
let percentage = Double(amount) / sumAmount
  • またGitHubの例を見る通り、%の小さいものはOtherにまとめられているので、それに倣っています。

  • コード全体としては以下の通りになります
Language+createSampleData.swift
extension Language {
    
    private static let otherThresholdPercentage = 0.004  // 0.4%未満をOtherとする
    
    static func createSampleData() -> [Language] {
                                
        // ref: https://api.github.com/repos/robovm/apple-ios-samples/languages
        guard
            let url = Bundle.main.url(forResource: "sample-repository-languages", withExtension: "json"),
            let data = try? Data(contentsOf: url),
            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Int]
        else {
            fatalError("sample-repository-languages.jsonの読み込みに失敗しました。")
        }
        
        let sumAmount: Double = json.reduce(0.0) { partialResult, dict in
            let amount = dict.value
            return partialResult + Double(amount)
        }
        
        var languages = [Language]()
        var otherAmount = 0
        var otherPercentage: Double = .zero
        
        for (name, amount) in json {
            let percentage = Double(amount) / sumAmount
            if percentage < otherThresholdPercentage {
                // 一定値以下のものをOtherにまとめる
                otherAmount += amount
                otherPercentage += percentage
                continue
            }
            
            languages.append(
                Language(name: name,
                         amount: amount,
                         percentage: percentage,
                         color: GitHubLanguageColor.shared.getColor(withName: name) ?? .accentColor)
            )
        }
                                        
        languages.sort(by: { first, second in
            // 使用率が大きい順にソート
            first.amount > second.amount
        })
        
        if otherPercentage > 0 {
            languages.append(
                Language(name: "Other",
                         amount: otherAmount,
                         percentage: max(0.001, otherPercentage),
                         color: .otherLanguage)
            )
        }
                        
        return languages
    }
}

グラフの作成

基本

  • 今回使うグラフは下図のような積み上げ棒グラフでBarMarkを使用します。

  • とりあえず前述データを表示をすると以下のようになります。
    • Array(languages.enumerated())に関しては後述のpaddingで使うためこうしています。
    • 必要がなければ通常通りForEach(languages)でOKです。
Chart {
    ForEach(Array(languages.enumerated()), id: \.element.name) { index, language in
        BarMark(
            x: .value("Percentage", language.percentage)
        )
        .foregroundStyle(language.color)
    }
}

体裁の調整

  • Chartのmodifierを使って体裁を整えていきます。
  • chartPlotStyleでグラフの表示領域を設定し、両端に丸みをもたせます
  • また今回x軸は不要なので消しています。
Chart {
...
}
.chartPlotStyle { plotArea in
    plotArea
        .frame(height: 10)
        .cornerRadius(5)
}
.chartXAxis(.hidden)

凡例の追加

  • 凡例を追加します。今回テキストと色を指定したいのでカスタムで設定する必要があります。
  • chartForegroundStyleScale(_:)の引数がKeyValuePairs<DataValue, S>であり、これを動的に作るのは難しそうでした。
  • そのため`chartForegroundStyleScale(domain:type:)を使っています。
  • これにより凡例の文字列のカスタムまで行っています。
.chartForegroundStyleScale(
    domain: languages.map { $0.titleForLegend },
    range: languages.map { $0.color }
)

paddingの追加

  • 最後に要素の間にpaddingを追加します。
    • (ここの方法が調べても出てこなかったので、トリッキーな自己流の実装となっています。他にいい方法があれば教えて下さい…!)
  • 発想としてはSwift Charts: Raise the barであった下記のRectangleMarkを使います。
  • paddingを擬似的に表現するため、要素の間にRectangleMarkを配置します。

  • 下記のようにForEachの中にRectangleMarkを追加しています。
  • 最後の要素の後だけはpaddingがいらないので、.foregroundStyle.clearとしています。
Chart {
    ForEach(Array(languages.enumerated()), id: \.element.name) { index, language in
        BarMark(
            x: .value("Percentage", language.percentage)
        )
        .foregroundStyle(language.color)
        
        RectangleMark(
            x: .value("padding", calculatePaddingXPosition(at: index)),
            width: 4
        )
        .foregroundStyle(
            (index == languages.count - 1) ? .clear : .otherLanguage
        )
    }
}
  • またRectangleMarkの位置は地道に計算しています。
private func calculatePaddingXPosition(at index: Int) -> Double {
    if index == languages.count - 1 {
        return .zero
    }
    
    var xPosition: Double = .zero
    for markIndex in 0...index {
        xPosition += languages[markIndex].percentage
    }
    
    return xPosition
}
  • 以上で完成です!

  • 余談ながらpaddingとOtherの色を淡いグレーで合わせているのがミソだったりします。
  • もしpaddingの色を背景色(Color(uiColor: .systemBackground))にした場合、下記のようにOtherの表示が乱れる結果となります。

Discussion