5. 軸に目盛りをつける

(2021.08.29. 公開)

 前回はグラフと x 軸・y 軸を描いただけで、目盛りは描きませんでした。今回は目盛りを描くことにします。

5-1. 目盛りを描く領域を決める

 数学のグラフを描くときは、普通は「x 軸・y 軸」に数値や目盛りをつけます。

 しかし、座標平面の任意の位置を描画できるようにすると、「画面のどこにも x 軸・y 軸がない」という事態があり得ます。これは、表示範囲の x, y 座標が「0」を含んでいないときに起こります。そのような場合を特別扱いするよりは、統計グラフを描く時のように、グラフの左端・下端に必ず軸を描くことにして、そこに数値をつける方が、統一した取り扱いができます。今回はそういうやり方を採用してみます。

 グラフを描く領域の左端・下端に、「軸と目盛りを描く」領域を確保します。それぞれの幅・高さを frame.ax_width, frame.ax_height で表すことにしましょう。

  --  グラフの描画範囲を決める
  --  画面左下:(x0, y0)
  --  画面の1ポイント = scale
  frame.x0 = -2
  frame.y0 = -2
  frame.scale = 0.01
  --  目盛りを描く領域の幅・高さ:ax_width, ax_height
  frame.ax_width = 40
  frame.ax_height = 24

 軸につける数値を描くためのフォントも最初に作成しておきます。フォントは wxFont 型のオブジェクトです。

  --  軸ラベルのフォント
  frame.font = wx.wxFont(10, wx.wxFONTFAMILY_SWISS, wx.wxFONTSTYLE_NORMAL, wx.wxFONTWEIGHT_NORMAL)

 wxFont のコンストラクタの引数は次の意味を持ちます。

 上のコード例でわかりますが、フォント名を直接指定することは wxWidgets では推奨されていません。フォント名は OS 依存であり、wxWidgets は OS に依存しないプログラミングを目指しているためです。

5-2. 目盛りの刻み幅を決める

 今回のグラフでは、表示する範囲は決まっています。画面の幅・高さを width, height とすると、x 軸の範囲が frame.x0 から frame.x0 + (width - frame.ax_width) * frame.scale, y 軸の範囲が frame.y0 から frame.y0 + (height - frame.ax_height) * frame.scale です。この範囲の軸に、「いい感じで」目盛りをつけることを考えてみましょう。下のようなイメージです。

 画面サイズを変えると、目盛りのつき方も変わります。

 これをコーディングするのは、なかなか骨が折れます。最初に、方針を決めましょう。

 線だけを引く「小さな目盛り」と、ラベルをつける「大きな目盛り」を区別することにします。「大きな目盛り」の間隔は、当然「小さな目盛り」の整数倍になります。あまりこの倍数が大きいと、小さな目盛りを数えるのが大変ですから、せいぜい5倍ぐらいまでに収まるようにしましょう(この図では4倍)。

 次に、目盛りの刻み幅を考えましょう。これは、キリのいい値になっていないと困ります。例えば、「大きい目盛り」の刻み幅が「1」だとして、小さい目盛りがこんな風についていたら、ちょっとイヤですよね。

 大きい目盛りを「3等分」するからいけないわけです。「2等分」「4等分」「5等分」なら、きっちり割り切れますから、こういう問題は起きません。「4等分」だと 0.25 刻みになりますが、これは許容範囲でしょう。(イヤな人は、0.25 を使わないようにすればよい)

 また、下のような目盛りの付け方はどうでしょうか。3, 6, 9, 12, ... と、次のキリのいい値がなかなか出てきません。下の図のように 0 から始まっていたらあまり気にならないけど、画面に出ているのが 18, 21, 24, ... みたいな値だったらどうでしょう。ちょっとイヤだと思いませんか。

 「キリのいい値」がなるべく頻繁に出てくるようにするには、「大きな目盛り」の刻み幅を、「1, 2, 5 のどれか」× 10N にすればよいのです。N = -1, 0, 1 で考えると、0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50 となります。なぜ「1, 2, 5」かというと、これらは 10 の約数だからです。

 「小さな目盛り」の刻み幅は、これを2〜5分割します。こちらも「キリのいい値」になるようにするには、次の選択肢の中から選ぶことになります。

大きな目盛り小さな目盛り
10.2
0.25
0.5
1
20.5
1
2
51
2.5
5

5-3. 画面上の目盛りの間隔を決める

 次に、画面上の目盛りの間隔について、考えてみましょう。今度は、「大きな目盛り」の方からいきます。画面の中に数値が1つしか表示されていないと、目盛りの刻み幅が読み取れませんから、2つ以上の数値を表示する必要があります。このためには、大きな目盛りの間隔を、軸を表示する幅の1/2以下にします。これで、目盛りの間隔の「上限値」が決まります。

 「下限値」はどうやって決めればいいでしょうか。あまり細かく目盛りを刻むと、かえって見づらくなりますよね。

 「小さな目盛り」の間隔の目安を、ピクセル幅で決めておきましょう。ここでは、60ピクセルとします。ただし、「小さな目盛りの間隔は必ず60ピクセル以下」と決めてしまうと、大きな目盛りの間隔の条件と競合することがあります。そのため、条件としては「小さな目盛りの間隔は、60ピクセルを越えない範囲でなるべく大きな値とする」ことにします。下の図にある通り、「60ピクセル」はかなり広めの間隔です。

5-4. 目盛りの間隔を計算する

 画面の表示幅から、上に示した条件を満たすように、目盛りの間隔を計算します。手順は下の通りです。

  1. 「小さな目盛り」の間隔の目安 (60 ピクセルを軸のスケールに換算した値) を越えない範囲で、なるべく大きな「キリのよい値」を決める。これが「小さな目盛り」の間隔になる。
  2. 「大きな目盛り」の間隔の上限を越えない範囲で、小さな目盛り何個で大きな目盛りになるかを決める。

 1. は、下のように実装します。

  local minor_hint = 60 * frame.scale  -- 小さな目盛りの間隔の目安
  local minor_tick  --  小さな目盛りの間隔(軸のスケールに換算した値)
  local ex = math.pow(10, math.floor(math.log10(minor_hint)))  -- キリのよい値の単位
  local r = minor_hint / ex  -- 「目安」は ex の何倍?
  if r >= 5 then
    minor_tick = 5 * ex  -- 5刻み
  elseif r >= 2.5 then
    minor_tick = 2.5 * ex -- 2.5刻み
  elseif r >= 2 then
    minor_tick = 2 * ex  -- 2刻み
  else
    minor_tick = ex  -- 1刻み
  end

 小さな目盛りの間隔が決まってしまえば、2. の実装は難しくありません。この場合も r の値によって場合分けが必要なので、1. と合わせてコーディングするのが合理的です。

  if r >= 5 then
    minor_tick = 5 * ex  -- 5刻み
    if minor_tick * 4 <= major_hint then
      major_tick = minor_tick * 4
    elseif minor_tick * 2 <= major_hint then
      major_tick = minor_tick * 2
    else
      major_tick = minor_tick
    end
  elseif r >= 2.5 then
    minor_tick = 2.5 * ex -- 2.5刻み
    if minor_tick * 4 <= major_hint then
      major_tick = minor_tick * 4
    elseif minor_tick * 2 <= major_hint then
      major_tick = minor_tick * 2
    else
      major_tick = minor_tick
    end
  elseif r >= 2 then
    minor_tick = 2 * ex  -- 2刻み
    if minor_tick * 5 <= major_hint then
      major_tick = minor_tick * 5
    else
      major_tick = minor_tick
    end
  else
    minor_tick = ex  -- 1刻み
    if minor_tick * 5 <= major_hint then
      major_tick = minor_tick * 5
    elseif minor_tick * 2 <= major_hint then
      major_tick = minor_tick * 2
    else
      major_tick = minor_tick
    end
  end

5-5. 軸にラベルを書く

 軸を書くところは gc:StrokeLine() を使えばよいので、特に説明は不要でしょう。ラベルを書くところは説明が必要です。縦軸・横軸のラベルは、それぞれ下のように書きたいわけです。

 つまり、ラベル文字列の幅・高さを計算する必要があります。これは、wxGraphicsContextGetTextExtent() メソッドを使えば求められます。width, height, descent, externalLeading の4つの値が戻り値として得られます。ここでは幅と高さだけわかればよいので、2個の戻り値を使います。文字列を書くのは、DrawText() です。

  local swid, shigh = gc:GetTextExtent(str)
  -- ラベルテキストを書く
  if is_horizontal then
    gc:DrawText(str, pos - swid / 2, ys + dp + 1)
  else
    gc:DrawText(str, xs + dp + 1, ys - (pos + shigh / 2))
  end

GetTextExtent() のように、複数の値を返すメソッドは、C++ では変数のアドレスを渡す仕様になっています。一方、wxLua では、変数のアドレスが渡せない代わりに、複数の値を返せるので、API の仕様が異なってきます。このあたりのドキュメントが wxLua は整備されていなくて、下手すると wxLua のソースコードを読む羽目になります。ここはなんとかしないといけませんよね……

 目盛りとグリッドを書いて、だいぶ見栄えが整ってきました。次は、マウスを使って画面の拡大・縮小をやってみます。

 本章のプログラム: [graphcalc05.wx.lua]

目次