この記事は韓国語から翻訳したものです。不十分な部分があれば、いつでもフィードバックをいただければありがたいです! (オリジナル記事, 同じく私が作成しました。)
グループプロジェクトでグラフライブラリを実装する過程をまとめてみました。今回の記事ではその中で核心部分であるグラフ軸を描く部分について説明します。(project repo, library repo)
考慮すべきこと
実装する前、軸を描く時どのようなことを考慮する必要があるのか簡単に書いてみました。
- スケールの数
- スケールあたりの範囲
- 目盛ごとの間隔 (px)
- 軸の長さと余白
最初はこのように考えて実装したのですが、テストをしていくうちに考慮しなければならない部分がどんどん増えてきました。まず、どのような順番で描くか決めました。
描く順番
最初は次のように実装しました。
1.軸を描くことができる長さを測定
2.目盛りごとの範囲計算
3.目盛りごとの間隔と個数計算
4.軸の描画
5.目盛りの描画
6.ラベル描画
グラフを目盛りの比率に合わせて描くために何度も修正を繰り返し、最終的には次のように描く。
1.軸を描ける長さを測定
2.軸上で実際にラベルが描かれる範囲を測定する。
3.各スケールごとの間隔と個数を計算する。
4.各尺度ごとの範囲計算
5.軸の描画
6.最小、最大目盛りを描く
7.最小または最大目盛りから目盛りごとの間隔分だけ目盛りを描画します。
8.ラベル描画
9.ラベル描画中に重なる場合、ラベルを傾けて再描画します。
10.ラベルを傾けて描いた後、切れて見える場合、必要な余白を計算して再描画します。
ほぼ2倍の工程が追加されました!それでは、上記のステップを具体的にどのように実装したのか見てみましょう。
描画
0. プリセット
Canvasの全てのViewは全てpx単位で計算します。しかし、私たちがよくアンドロイド開発をする時、ディスプレイのピクセル密度と独立したdp単位を使います。なぜなら、同じサイズの二つの画面でピクセル数が違う場合があるため、Canvasでpx単位で計算すると、携帯電話ごとにViewの内部内容が同じように見えないし、壊れてしまうことがあります。これを簡単に処理するため、PxクラスとDpクラスを作り、この二つを相互に変換できるようにしました。
data class Px(val value: Float)
data class Dp(val value: Float)
fun Px.toDp(context: Context): Dp {
val density = context.resources.displayMetrics.density
return Dp(value / density)
}
fun Dp.toPx(context: Context): Px {
val density = context.resources.displayMetrics.density
return Px(value * density)
}
携帯電話のディスプレイの密度を取得するためにはcontextが無条件に必要なので、関数パラメータでContextを追加しました。
追加で描く前に知っておくべきことがあります。Canvasの座標は左上から0,0で始まり、xは右へ、yは下へ行くほど座標値が上がります。
1.軸
最初に軸を描く前に、軸を描ける長さを測る必要があります。Viewがあれば単純に左端、右端から線を引けばいいのではと思うかもしれないが、目盛りも描かなければならないし、ラベルも付けなければならない。そのためにまず各軸の余白の始点、終点をDpで指定する。まずは初期値として32dpを設定しておく。
// Margin: Empty space from the view to the graph on the outside. This includes the other side as well (Like horizontal & vertical margins)
var xAxisMarginStart: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
var xAxisMarginEnd: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
var yAxisMarginStart: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
var yAxisMarginEnd: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
どちらの軸のどちらが始まりと終わりなのか少し混乱するかもしれないが、図でまとめると次のようになる。Startは無条件にx軸とy軸が交差する0,0地点、そしてEndは各軸が終わる地点に設定しました。
だから、実際に軸を描く範囲は Viewの横の長さ - 軸のMargin Start - 軸のMargin End
となります。Viewのwidthの値はpxで、各軸のmarginの単位はdpなので、これを必ず変換して処理します。(演算の便宜のため各単位の四則演算のオーバーロードを追加しました)
// Calculate available axis space
val availableSpace: Dp =
Px(width.toFloat()).toDp(context) - xAxisMarginStart - xAxisMarginEnd
さらに、軸が終わるところまで目盛りがあると見栄えが悪いので、そのために軸ごとにpadding値を追加し、ラベルが実際に描画される範囲を計算します。
val availableLabelSpace: Dp = availableSpace - xAxisPadding
2.目盛り
目盛り数、間隔
最初は、目盛り間隔のデフォルト値をdpに設定し、その間隔で何個の目盛りを描くことができるかを計算し、その目盛り数に合わせて目盛り単位を計算する方法で実装していました。完璧だと思ったこのロジックには致命的な問題がありました。目盛り間隔のデフォルト値を決めておいたため、Viewのサイズが小さくなると、Viewのサイズに比べて目盛り間隔のデフォルト値が大きくなり、結果的に、小さくても十分に目盛りを描くことができるViewの状態であるにもかかわらず、目盛りが非常に少なくなり、グラフの目盛りとして効果がない現象が発生しました。
この問題を解決するために、次のように目盛り数ロジックを修正しました。
- 目盛り間隔のデフォルト値はまだ存在します。
- 目盛りを描く間隔の範囲によって目盛りの数を指定する。
- 一定範囲以上であれば自動的に処理
var xAxisSpacing: Dp = Dp(32F)
var yAxisSpacing: Dp = Dp(32F)
private fun getAvailableLabelCount(availableLabelSpace: Dp, spacing: Dp): Int {
// Number of ticks
// ~ 150dp : 3 (max, min, 50%)
// ~ 250dp : 5 (max, min, 25%, 50%, 75%)
// 250dp ~ : Auto
return when {
availableLabelSpace.value <= 150F -> 3
availableLabelSpace.value <= 250F -> 5
else -> {
(availableLabelSpace / spacing).value.toInt()
}
}
}
val availableLabels = getAvailableLabelCount(availableLabelSpace, xAxisSpacing)
そのViewのサイズから描画する目盛りの数を求めたら、次に目盛りの単位を求めます。目盛りの単位を求めた後、目盛りの単位に基づいて比率に合わせてラベル間の間隔を修正します。これは、始点と終点の目盛りを無条件に入力されたデータの最小値と最大値に設定したのと似たような理由ですが、線グラフを描く時、入力されたデータから線をどのくらい長く描くか比率が一定でなければ、目盛りと正確に比率に合わせて描くことができないからです。最大 / 最小値で目盛りの範囲を設定すると、最大 - 最小
の値から (各値 - 最小値) / (最大 - 最小)
で比率を求めることができ、これに合わせてグラフを描くと、目盛りも比率に合わせて描いたので、互いに一致するようになります。
// unit: 1目盛りあたりの単位(下記に記載予定)
// difference: 最大値 - 最小値
val actualSpacing = availableLabelSpace * Dp(unit / difference)
尺度単位
単純にMin-Max Normalizationに使われる方法を利用して (最大 - 最小値) / ラベル数
で計算すれば本当に楽ですが、この方法は実装する前から問題があることを知っていました。これは完璧に目盛りの間隔もきれいに処理できる方法ですが、目盛りごとの単位の間隔がおかしくなる可能性があるという欠点があります。
例えば、データの範囲が1~10の状況で、4つの目盛りを描く場合、n-1個で軸が分割され、単位ごとに3つずつきれいに分割されます。 (1、4、7、10) しかし、私たちが実際に受け取るデータはそのような小さな数字ではなく、きれいではありません。もしデータの範囲が25340 ~ 32710で、目盛りを6つ描かなければならないとしたらどうなるでしょうか? そうすると、5マスに分割され、単位あたり1474で切れてしまいます。これをグラフに描くとどうなるだろうか? グラフの目盛りは25340, 26814, 28288, ...このように単位の数字が曖昧になってしまう。 せめて目盛りの単位は、比較的人が直感的に見て、どのくらいの単位まで上がるのかすぐに計算できるレベルで表示されればいいと思い、2番目に大きい桁から切り上げた値を単位に設定することにした。
private fun roundToSecondSignificantDigit(number: Float): Float {
if (number == 0F) {
return 0F
}
val digit = floor(log10(number))
val power = 10F.pow(digit)
val result = (number / power).roundToInt() * power
if (result < number) {
return result + power
}
return result
}
val unit = roundToSecondSignificantDigit(difference / (availableLabels - 1).toFloat())
(最大値 - 最小値) / ラベル数
とした値を2番目に大きい桁から切り上げたので、目盛りの単位を比率に合わせて描くと、ぴったり割り切れないようになる。そのために、最大最小目盛りを先に描いた後、最大または最小目盛りで単位分だけ足し算して次々に描き、残る部分は単位間隔とは異なる間隔が残るようにした。しかし、この残された間隔は、実際の次の目盛りとの値の差と間隔の割合が他の目盛り間隔と同じなので、問題は生じない。末尾が160で終わるのは、目盛りの開始値が160で終わるので、この方法の致し方ない限界点である。
// Min, Max Tick
drawAxisTick(canvas, minPointX, tickStartPointY, minPointX, tickEndPointY, xAxisPaint)
drawAxisTick(canvas, maxPointX, tickStartPointY, maxPointX, tickEndPointY, xAxisPaint)
(neededLabels - 1 downTo 1).forEach { idx ->
val tickPointX: Px = maxPointX - actualSpacing.toPx(context) * Px(idx.toFloat()) // X축 눈금 시작 X좌표
if (tickPointX.value >= axisStartPointX.value) {
drawAxisTick(canvas, tickPointX, tickStartPointY, tickPointX, tickEndPointY, xAxisPaint)
// TODO: ラベル作成
}
}
(追加) 上記の限界点を解決するため、プロジェクトが終わった後、最小値を半減し、半減された値の差分だけ目盛りの比率を調整する方法を実装してみました。既存のコードと最も互換性があるようにデータの最小値を半減した値に変更した後、目盛りは最小値を半減した部分から計算をし、実際にグラフを描く時は半減された値の差分だけ目盛りを減らした総軸の長さで既存の比率に合うように描くように実装しました。
val realMinY = chartData.minOf { it.y }
val minY = roundDownToSignificantDigit(realMinY, (maxY - realMinY).toInt().toString().length)
val minToRealMinSpacing = Dp(realMinY - minY) * actualSpacing / Dp(unit)
// 実際のグラフを描画する最小Y値.`minToRealMinSpacing` を計算して,切り捨てられた値の差の目盛りの大きさを計算し,それをグラフを描画する最小Y値に反映させる.
val minPointY: Px = (axisStartPointY.toDp(context) - (yAxisPadding / Dp(2F)) - minToRealMinSpacing).toPx(context)
3.ラベル(目盛りテキスト)
ラベルは目盛りがラベルの中央に来るように描きます。ここで一つ注意することは canvas.drawText()
をする時、テキストを上の図の赤い点を基準にして描きます。(正確にはbaselineの開始点)
paint.getTextBounds(label, 0, label.length, bounds)
val textWidth = Px(bounds.width().toFloat())
val textHeight = Px(bounds.height().toFloat())
// Print label text parallel to its axis
val labelStartPointX: Px = startPointX - textWidth / Px(2F)
val labelStartPointY: Px = startPointY + marginTop.toPx(context) + textHeight // Baselineの考慮
canvas.drawText(label, labelStartPointX.value, labelStartPointY.value, paint)
a. 軸のラベルが重なる場合
軸を描く時、ラベルが長くて重なる場合が発生しました。このような場合、読みやすさのためにラベルを45度傾けてViewを再描画するように処理しました。
// Print label text diagonally
val labelStartPointX: Px = startPointX
val labelStartPointY: Px = startPointY + marginTop.toPx(context) + textHeight
canvas.save()
canvas.rotate(
45F,
labelStartPointX.value + textHeight.value,
labelStartPointY.value - textHeight.value
)
canvas.drawText(label, labelStartPointX.value, labelStartPointY.value, paint)
canvas.restore()
drawText()
を行う際に角度を傾けた状態で描くというオプションがないので、canvas自体を回転させた後、drawText()
を行った後、再びcanvasを元に戻す必要がある。ここで気をつけなければならないのは、canvasをどの座標点で回転させるかが重要です。好きな位置に傾けた状態で描画するには、ラベルの高さだけ右斜め上を基準に回転させて描画した後、再びcanvasの回転を戻す。
b. 軸ラベルが切れて見える場合
ラベルを45度傾けてViewを再描画した場合、ラベルが描かれる側の余白よりラベルが長くなることがあります。この部分も追加で考慮し、自動的に余白を修正するように処理しました。
if ((yAxisMarginStart - halfTickLength - marginTop).toPx(context).value < textWidth.value * ONE_OVER_SQRT_2) {
// Automatically adjusts the graph margin
yAxisMarginStart = Px(
(textWidth.value * ONE_OVER_SQRT_2).roundToInt().toFloat()
).toDp(context) + marginTop + halfTickLength + Dp(8F)
}
ラベルを45度傾けたので、実際の高さはラベルの横幅の 1/√2
になります。Margin変数はカスタムセッターで値が変更されると無効化されるので、自動的に再描画されます。
c. 最後に描いた目盛りのラベルが既存のラベルと重なっている場合
最小、最大目盛りとラベルを描き、2つのポイントの一つから目盛りを順番に描くと、最後に目盛りの間隔が違う目盛りが出てくることがあります。(上記の目盛り単位を参照)そのため、差が非常に小さいときにラベルが重なることもあるので、この部分だけ考慮すれば終わりです。
if (tickPointX.value < boundX.value) {
// Overlapping first tick. Ignore label text
drawAxisTick(canvas, tickPointX, tickStartPointY, tickPointX, tickEndPointY, paint)
return@forEach // ラベルを描くforEachの中に入れます。
}
次の記事では、グラフデータの作成について投稿したいと思います。