4. グラフを描く

(2020.01.25. 公開)

 いよいよ、グラフの描画を行います。wxWidgets でグラフィックの表示を行うための方法を調べていきましょう。

4-1. 画面表示のイベント

 wxWidgets で任意の表示を行うためには、wxEVT_PAINT というイベントをつかまえます。ここでは、グラフを表示する部品 (wxPanel) が frame.panel に入っていますから、これに対してイベントハンドラを登録します。

  frame.panel:Connect(wx.wxEVT_PAINT, OnPaint)

 イベントハンドラには、表示を行う動作を記述します。wxWidgets では、グラフィック表示を行う方法が2通りあります。一つは wxPaintDC を使う方法、もう一つは wxGraphicsContext を使う方法です。wxPaintDC は古いインターフェイスであり、今から書くコードではなるべく wxGraphicsContext を使うことが好ましいでしょう。

 イベントハンドラの骨格は、下のようになります。先に述べた通り、 frame.panel は、描画を行う wxPanel です。

--  描画イベントハンドラ
function OnPaint(event)
  --  wxGraphicsContext を作成
  local dc = wx.wxPaintDC(frame.panel)  -- 先に wxPaintDC を作成
  local gc = wx.wxGraphicsContext.Create(dc)
  if gc then
    --  gc を使って描画内容を記述
    gc:delete() -- wxGraphicsContext を破棄
  end
  dc:delete()   -- wxPaintDC を破棄
end

 ウィンドウ (frame.panel) から、まず wxPaintDC を作り、それを元に wxGraphicsContext を作ります。成功したら、 wxGraphicsContext のメソッドを使って、描画内容を記述します。

 描画が終了したら、 wxGraphicsContextwxPaintDC を破棄します。描画のイベントハンドラは、画面更新が起きるたびに呼び出されますが、wxGraphicsContext はその都度作成し、破棄しなければなりません。Lua のガーベジコレクタに破棄を任せるのではなく、明示的に破棄してください。

 wxPaintDC を経由せずに wxGraphicsContext を作成するメソッドもありますが、画面更新がうまくいかないことがあるようです。wxPaintDC を経由する方が確実です。

4-2. 表示範囲を決める

 グラフは「xy平面」上にあります。xy平面は無限に広がっていますから、どの範囲を画面に表示するかを決めておかなくてはなりません。ここでは、「画面の左下」が点 (-2, -2) 、「画面上の 100 ピクセル」が座標軸の長さ1になるものとしましょう。これらの値は、あとで変更できるようにしたいので、frame のテーブルフィールドに入れておきます。

  frame.x0 = -2  -- 画面左下の x 座標
  frame.y0 = -2  -- 画面左下の y 座標
  frame.scale = 0.01  -- 画面の1ピクセルあたりの長さ

 数学の xy 平面は、普通 x 軸が右方向、y 軸が上方向です。しかし、 wxGraphicsContext の座標は、左上が原点で、x 軸が右方向、y 軸は下方向です。

 画面の高さを height とすると、左下の画面座標は (0, height) となります。これより、画面座標とグラフ座標の変換は、下のようになります。

 グラフを描くには、画面の左端から始めて、x 座標を1ピクセルずつ増やしながらグラフ上の点の位置を計算し、それを折れ線で結びます。下の図だと「折れ線」であることが目立ってしまいますが、実際には点の横方向の間隔は1ピクセル分なので、十分滑らかな曲線に見えるでしょう。

4-3. 画面にグラフを描く

 描画の処理は、 wxGraphicsContext のメソッドを組み合わせて記述します。

 最初にやるべきことは、前に表示されていた(かもしれない)内容を消すことです。そのために、frame.panel 全体を白で塗りつぶすことにします。四角形を描くには DrawRectangle() を使います。このメソッドは、「現在の『ブラシ』で四角形の内容を塗りつぶし、その後に現在の『ペン』で四角形の外周を描く」という動作をします。「ブラシ」は SetBrush(), 「ペン」は SetPen() で指定します。ここでは、塗りつぶす色は白、外周は「書かない」という想定なので、それに対応するブラシ、ペンを設定してから、DrawRectangle() を呼びます。

  local size = frame.panel:GetClientSize()  -- frame.panel のサイズ
  local width = size:GetWidth()             -- 横幅
  local height = size:GetHeight()           -- 高さ
  gc:SetBrush(wx.wxWHITE_BRUSH)             -- 「白一色」のブラシ
  gc:SetPen(wx.wxNullPen)                   -- 「空白」のペン
  gc:DrawRectangle(0, 0, width, height)     -- 四角形を描く

 次に、座標軸を描きます。本当は原点に "0" を書いたり、目盛りをつけたりするところですが、そのあたりは後で考えることにして、今回は x 軸と y 軸だけを描いておきます。線分を描くには、ペンを設定してから StrokeLine() を使います。

  gc:SetBrush(wx.wxNullBrush)                 -- 塗りつぶしをやめる
  gc:SetPen(wx.wxBLACK_PEN)                   -- 黒色、太さ1ピクセルのペン
  local ox = -frame.x0 / frame.scale          -- グラフ原点の画面上の x 座標
  local oy = height + frame.y0 / frame.scale  -- グラフ原点の画面上の y 座標
  gc:StrokeLine(0, oy, width, oy)             -- x 軸を描く
  gc:StrokeLine(ox, 0, ox, height)            -- y 軸を描く

 いよいよグラフ本体です。グラフは折れ線で表現します。折れ線を描くには、「パス」を使います。最初に、CreatePath() で新しいパスを作成し、MoveToPoint() で最初の点を指定し、2番目以降の頂点を AddLineToPoint() で追加していきます。最後に、StrokePath() で線を描きます。

  if frame.func then               -- frame.func が定義されていることを確認
    local gpath = gc:CreatePath()  -- 新しいパスを作る
    for xx = 0, width do           -- 画面の左端から右端まで
      local yy                     -- (xx, yy) が画面座標
      local x, y                   -- (x, y) はグラフ座標
      x = xx * frame.scale + frame.x0  -- xx をグラフ座標に変換
      y = frame.func(x)            -- 関数値を計算
      yy = height - (y - frame.y0) / frame.scale  -- y を画面座標に変換
      if xx == 0 then
        gpath:MoveToPoint(xx, yy)  -- 最初の点
      else
        gpath:AddLineToPoint(xx, yy) -- 2番目以降の点
      end
    end
    gc:StrokePath(gpath)           -- 折れ線を描く
  end

4-4. 画面更新のタイミング

 画面更新は、次のようなタイミングで起こります。

 本プログラムでは、「更新」ボタンを押すと関数が変更されるので、この時には Refresh() を呼ぶ必要があります。このため、OnClick() 関数に1行追加します。

function OnClick(event)
  local eq = frame.textctrl:GetValue()
  local str = "frame.func = function (x) local y\n" .. eq .. "\nreturn y; end"
  local f, errmsg = loadstring(str)
  if f then
    f()
    frame.etext:SetLabel("")
  else
    frame.etext:SetLabel(errmsg)
  end
  frame:Refresh()  -- 画面を更新する
end

 画面サイズを変更した時の処理について、説明しておきます。 frame.panel を作成した時に、ウィンドウスタイルとして wx.wxFULL_REPAINT_ON_RESIZE を指定したことを思い出してください。このスタイルを指定した場合と指定しない場合とでは、画面サイズを変更したときの動作が少し異なります。

 wx.wxFULL_REPAINT_ON_RESIZE を指定しない場合、画面の書き換えがなるべく少なくなるように処理が行われます。このため、画面サイズが小さくなった時は、描画イベントは発生しません。画面サイズが大きくなった時は、右側・下側の「今まで表示されていなかった部分」のみが描画されます。表示する内容が、ウィンドウの「左上」に固定されている場合は、このような処理で問題ありません。

 一方、今回のプログラムでは、画面の「左下」を「グラフ座標の特定の点」と想定して描画を行なっています。つまり、表示内容は画面の「左下」に固定されています。この場合は、上のような処理では、うまく画面を書き換えることができません。そこで、wx.wxFULL_REPAINT_ON_RESIZE を指定します。この指定があれば、画面サイズが変更された場合は必ずウィンドウ全体が書き換えられるため、表示内容を正しく更新することができます。(そのかわり、少しちらつきが発生することがあります。)

 wx.wxFULL_REPAINT_ON_RESIZE は、GTK+2 と Windows でのみ機能します。その他のプラットフォーム(MacOS を含む)では、常に画面全体が書き換えられます。

 これで、グラフ表示ができるようになりました。

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

目次