[SwiftUI][SwiftCharts] 複数系列のデータを1つのグラフに表示する

SwiftUI2021

     
⌛️ 4 min.

Swift Charts を使って、複数の(異なる単位の)系列データを1つのグラフに表示する方法を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS14.5 Beta
  • Xcode 15.3
  • iOS 17.4
  • Swift 5.9

Swift Charts

Apple 謹製の グラフ表示ライブラリです。

さまざまな表示が、デフォルトでそれっぽく表示されるのが Apple っぽいです。


参考
Swift ChartsApple Developer Documentation

LineMark を使った 折れ線グラフの表示方法は以下で説明しています。
SwiftUI2021 [SwiftUI][Charts] Chart で 折れ線グラフを表示する方法

複数系列のデータを重ねて表示したい

Swift Chart をいろいろな箇所に使うようになってくると、複数のデータを 1つのグラフ上に重ねて表示したくなります。

同じ単位系での複数のデータであれば、簡単です。

単位が同じであり、(値域も似ていれば) 複数の LineMark を使うだけで重なって表示されます。

しかし、ケースによっては異なる単位系のデータを重ねて表示したくなります。

ですが、デフォルト(?) の Swift Chart では、このようなデータは重ねて表示できません。

MEMO

ZStack を駆使して 重ねるという方法もあるのかもしれませんが、この記事では検討対象としていません。

なお、以降では、グラフの値域(Y軸)が異なるケースを検討し、定義域(X軸)が異なるケースは想定していません。

テストデータ

なんとなく(?)、テストの点数と そのテストの時の気温をデータにしてみました。

点数は、0~150 を取り得、気温は、-20~50 を取り得る という前提です。

struct GraphData: Identifiable {
    var id: UUID = UUID()
    var index: Int
    var value1: Double // 0...150     // point
    var value2: Double // -20...100   // temperature degree in Celsius
}

let graphData: [GraphData] = [GraphData(index: 0, value1: 125, value2: 23),
                              GraphData(index: 1, value1:  80, value2: 32),
                              GraphData(index: 2, value1: 100, value2: 15),
                              GraphData(index: 3, value1: 130, value2: 17),
                              GraphData(index: 4, value1: 115, value2: 21),
                              GraphData(index: 5, value1:  90, value2: -5)]

このデータを 1つのグラフ上に表示していきます。

変換しないまま LineMark

LineMark をそのまま使用してみます。

今回のデータは、値域が 近いので、そのままでも、それっぽい(?) です。

import SwiftUI
import Charts

struct GraphData: Identifiable {
    var id: UUID = UUID()
    var index: Int
    var value1: Double // 0...150     // point
    var value2: Double // -20...100   // temperature degree in Celsius
}

let graphData: [GraphData] = [GraphData(index: 0, value1: 125, value2: 23),
                              GraphData(index: 1, value1:  80, value2: 32),
                              GraphData(index: 2, value1: 100, value2: 15),
                              GraphData(index: 3, value1: 130, value2: 17),
                              GraphData(index: 4, value1: 115, value2: 21),
                              GraphData(index: 5, value1:  90, value2: -5)]

struct ContentView: View {

    var body: some View {
        Chart(graphData, content: { data in
            LineMark(x: .value("index", data.index),
                     y: .value("Point", data.value1),
                     series: .value("Point", "Point"))
            .foregroundStyle(.cyan)
            LineMark(x: .value("index", data.index),
                     y: .value("Temperature", data.value2),
                     series: .value("Temperature", "Temperature"))
            .foregroundStyle(.orange)
        })
        .padding()
    }
}
pureLineMark

それっぽいですが、もう少し工夫したくなる気がします。

Chart の座標系を合わせる

当たり前ですが、複数の単位が異なるデータを重ねるのが難しいのは、単位が異なるからです。

どう重ねれば良いのかを、事前に決めることが必要です。
以下のように対応づけていくことにします。

具体的には、グラフで使用する値域を固定してしまい、ここでは、0…100 (Closed<Double>) とします。
そして、その値域に合わせるようにそれぞれの系列のデータを加工して表示するようにします。

例えば、値域として -20 ~ 50 の温度のデータについて考えてみます。
値域に合わせて表示するとは、-20 度の温度データは、グラフ上では、(Y軸上の) 0 に表示するということです。
50 度は、100 に表示しますし、15 度 (-20 ~ 50 のちょうど中間) は、50 に表示されることになります。

上記を それぞれの系列のデータに当てはめていきます。

事前準備 ClosedRange と map 関数

最初に、ClosedRange の extension として、以下の2つの関数を用意します。

1) 値が、値域のどのあたりに位置するか(ratio と呼んでいます)を計算する関数
2) 値域と ratio から、具体的な値を計算する関数

extension ClosedRange where Bound == Double {
    func ratio(for value: Bound) -> Bound {
        return (value - self.lowerBound) / (self.upperBound - self.lowerBound)
    }

    func value(from ratio: Bound) -> Bound {
        return self.lowerBound + (self.upperBound - self.lowerBound) * ratio
    }
}

ratio は、value の ClosedRange での ratio を求めています。
value は、ClosedRange 中で ratio を持つ value を求めています。

次に、上記の関数を使って、ある値域にある値を、別の値域での同一 ratio を持つ値に変換する関数を用意します。

    func map(_ value: Double, in range: ClosedRange<Double>, to toRange: ClosedRange<Double>) -> Double {
        let ratio = range.ratio(for: value)
        return toRange.value(from: ratio)
    }

range 上で定義されている value を toRange 上で同じ ratio を持つ値に変換しています。

この map 関数を使用することで、データ系列での値と グラフ上の座標値を相互変換できるようになります。

値域を合わせるように表示する

先ほど 作成した関数を使って、値域を変換してグラフ表示します。

import SwiftUI
import Charts

struct GraphData: Identifiable {
    var id: UUID = UUID()
    var index: Int
    var value1: Double // 0...150     // point
    var value2: Double // -20...100   // temperature degree in Celsius
}

let graphData: [GraphData] = [GraphData(index: 0, value1: 125, value2: 23),
                              GraphData(index: 1, value1:  80, value2: 32),
                              GraphData(index: 2, value1: 100, value2: 15),
                              GraphData(index: 3, value1: 130, value2: 17),
                              GraphData(index: 4, value1: 115, value2: 21),
                              GraphData(index: 5, value1:  90, value2: -5)]

extension ClosedRange where Bound == Double {
    func ratio(for value: Bound) -> Bound {
        return (value - self.lowerBound) / (self.upperBound - self.lowerBound)
    }

    func value(from ratio: Bound) -> Bound {
        return self.lowerBound + (self.upperBound - self.lowerBound) * ratio
    }
}

struct ContentView: View {
    var body: some View {
        let value1Range: ClosedRange<Double> = 0...150
        let value2Range: ClosedRange<Double> = -20...45
        let graphRange: ClosedRange<Double> = 0...100
        Chart(graphData, content: { data in
            LineMark(x: .value("index", data.index),
                     y: .value("Point", map(data.value1, in: value1Range, to: graphRange)),
                     series: .value("Point", "Point"))
            .foregroundStyle(.cyan)
            LineMark(x: .value("index", data.index),
                     y: .value("Temperature", map(data.value2, in: value2Range, to: graphRange)),
                     series: .value("Temperature", "Temperature"))
            .foregroundStyle(.orange)
        })
        .padding()
    }
    func map(_ value: Double, in range: ClosedRange<Double>, to toRange: ClosedRange<Double>) -> Double {
        let ratio = range.ratio(for: value)
        return toRange.value(from: ratio)
    }
}

以下のような表示になります。

LineMarkOnSameRange

なお、ここで、軸に表示されている 0 ~ 100 は、あくまでグラフ上の座標値の意味しか持ちません。
ですので、軸にラベル表示が欲しくなります。

軸のラベルを表示する

Y軸に表示されている数値はあくまで グラフ上の座標値なので、軸に付与して表示されても意味不明です。

ということで、AxisMarks を使って、適切なラベルを表示するようにします。

ポイントは3つです。
・データの系列は2つあるので、2つの AxisMarks を表示するのがわかりやすい
・position を使用して、グラフの右側/左側 指定して、2つの AxisMarks の表示位置を調整する
・グラフ上の数値は座標値なので、それぞれの系列での数値に逆変換してラベル表示を作る必要がある

という方針で作ると以下のようになります。

import SwiftUI
import Charts

struct GraphData: Identifiable {
    var id: UUID = UUID()
    var index: Int
    var value1: Double // 0...150     // point
    var value2: Double // -20...100   // temperature degree in Celsius
}

let graphData: [GraphData] = [GraphData(index: 0, value1: 125, value2: 23),
                              GraphData(index: 1, value1:  80, value2: 32),
                              GraphData(index: 2, value1: 100, value2: 15),
                              GraphData(index: 3, value1: 130, value2: 17),
                              GraphData(index: 4, value1: 115, value2: 21),
                              GraphData(index: 5, value1:  90, value2: -5)]
struct ContentView: View {
    var body: some View {
        let value1Range: ClosedRange<Double> = 0...150
        let value2Range: ClosedRange<Double> = -20...45
        let graphRange: ClosedRange<Double> = 0...100
        Chart(graphData, content: { data in
            LineMark(x: .value("index", data.index),
                     y: .value("Point", map(data.value1, in: value1Range, to: graphRange)),
                     series: .value("Point", "Point"))
            .foregroundStyle(.cyan)
            LineMark(x: .value("index", data.index),
                     y: .value("Temperature", map(data.value2, in: value2Range, to: graphRange)),
                     series: .value("Temperature", "Temperature"))
            .foregroundStyle(.orange)
        })
        .chartYAxis(content: {
            let value1AxisValues = stride(from: value1Range.lowerBound, through: value1Range.upperBound, by: 10)
                .map({ map($0, in: value1Range, to: graphRange) })
            AxisMarks(position: .leading, values: value1AxisValues, content: { axis in
                let actualValue = map(value1AxisValues[axis.index], in: graphRange, to: value1Range)
                AxisValueLabel("\(actualValue.formatted(.number)) points")
                    .foregroundStyle(.cyan)
                AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2], dashPhase: 2)).foregroundStyle(.blue)
            })
            let value2AxisValues = stride(from: value2Range.lowerBound, through: value2Range.upperBound, by: 5)
                .map({ map($0, in: value2Range, to: graphRange) })
            AxisMarks(position: .trailing, values: value2AxisValues, content: { axis in
                let actualValue = map(value2AxisValues[axis.index], in: graphRange, to: value2Range)
                AxisValueLabel("\(actualValue.formatted(.number)) celcius")
                    .foregroundStyle(.orange)
                AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2], dashPhase: 2)).foregroundStyle(.orange)
            })
        })
        .padding()
    }
    func map(_ value: Double, in range: ClosedRange<Double>, to toRange: ClosedRange<Double>) -> Double {
        let ratio = range.ratio(for: value)
        return toRange.value(from: ratio)
    }
}

AxisGridLine も付与してみています。

LineMarkOnSameRangeWithLabel

まとめ

Swift Charts で複数系列のデータを1つのグラフに表示する方法を説明しました。

Swift Charts で複数系列のデータを1つのグラフに表示する方法
  • グラフの値域を定義し、その値域にデータ系列のデータを変換して表示する
  • AxisMarks を使用する時は、グラフ値域 -> データ値域 という変換が必要

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です