Tech-Draft

rubyとかscalaとかjavascriptとか?AndroidやiOSもチマチマと。

Gruffで折れ線グラフを描く際のTIPS

rubyには面白いGemがたくさんありますが、その一つにグラフを描くためのライブラリ
Gruff Graphs for Ruby | Ruby on Rails for Newbies
があります。
このGemを用いれば折れ線グラフや円グラフなどを簡単に描くことができますが、少し微妙な部分があります。それを回避するMonkeyPatchを紹介します。

検証したバージョン

OS
Mac OS X Lion 10.7.2
imagemagick
6.7.1
ruby
1.9.2p290
rmagick
2.13.1
gruff
0.3.6

インストール

imagemagickやrmagickを事前にインストールする必要があるため、少し手間がかかります。本家サイトや下記サイトなどを参考に、インストールしてください。
noanoa 日々の日記 : Mac OS X Lion に Ruby グラフ描画用 Gruff をインストールする
Gruffでグラフを書いてみよう - まめ畑
Gruffでグラフ!格好いいグラフを簡単に生成 - OneRingToFind

とりあえず描く

とりあえず、下記のようにすれば折れ線グラフが描けます。日本語を出すためには、FONT指定が必須です。

#!/usr/bin/env ruby
# -*- encoding: utf-8 -*-

require 'rubygems'
require 'gruff'

g = Gruff::Line.new
g.font = "/Library/Fonts/Osaka.ttf"

g.labels = {0=>'01/01', 3=>'01/04', 6=>'01/07'}

g.data("ほげ", [1,2,3,4,5,4,3,2,1])
g.data("foooooooooooooooooooooooooooooooooooooooooooooo",  [9,9,9,8,8,8,7,7,7])
g.data("bar",  [3,2,1,3,2,1,3,2,1])

g.write('hoge.png')

f:id:n_matsui:20120101214223p:plain

y軸の最小値・最大値・増分を指定する

マニュアル等に従えば、minimum_value, maximum_value, y_axis_incrementを指定すればy軸の最小値・最大値・増分を指定できることになっています。
が、現バージョンではエラーになります。

#!/usr/bin/env ruby
# -*- encoding: utf-8 -*-

require 'rubygems'
require 'gruff'

g = Gruff::Line.new
g.font = "/Library/Fonts/Osaka.ttf"

g.minimum_value = 0
g.maximum_value = 15
g.y_axis_increment = 5

g.labels = {0=>'01/01', 3=>'01/04', 6=>'01/07'}

g.data("ほげ", [1,2,3,4,5,4,3,2,1])
g.data("foooooooooooooooooooooooooooooooooooooooooooooo",  [9,9,9,8,8,8,7,7,7])
g.data("bar",  [3,2,1,3,2,1,3,2,1])

g.write('hoge.png')
/Users/cube/.rvm/gems/ruby-1.9.2-p290@rails_3.1.1/gems/gruff-0.3.6/lib/gruff/line.rb:112:in `normalize': wrong number of arguments (1 for 0) (ArgumentError)
	from /Users/cube/.rvm/gems/ruby-1.9.2-p290@rails_3.1.1/gems/gruff-0.3.6/lib/gruff/base.rb:687:in `draw_line_markers'
	from /Users/cube/.rvm/gems/ruby-1.9.2-p290@rails_3.1.1/gems/gruff-0.3.6/lib/gruff/base.rb:536:in `setup_drawing'
	from /Users/cube/.rvm/gems/ruby-1.9.2-p290@rails_3.1.1/gems/gruff-0.3.6/lib/gruff/base.rb:508:in `draw'
	from /Users/cube/.rvm/gems/ruby-1.9.2-p290@rails_3.1.1/gems/gruff-0.3.6/lib/gruff/line.rb:53:in `draw'
	from /Users/cube/.rvm/gems/ruby-1.9.2-p290@rails_3.1.1/gems/gruff-0.3.6/lib/gruff/base.rb:487:in `write'
	from ./grufftest.rb:20:in `<main>'

エラーメッセージは「line.rbにはひとつの引数を取れるnormalizeメソッドなんか無いぜ!」って言ってますが、line.rbのソースを見ると確かに引数をひとつ取れるnormalizeメソッドは存在しません。
下記の引数のないnormalizeメソッドのみが定義されています。

  def normalize
    @maximum_value = [@maximum_value.to_f, @baseline_value.to_f].max
    super
    @norm_baseline = (@baseline_value.to_f / @maximum_value.to_f) if @baseline_value
  end

Lineクラスの親クラスであるBaseクラスを確認すると、def normalize(force=false)という引数有りメソッドが定義されていますが、このメソッドはprotectedです。

つーことで、サクっとline.rbにMonkey Patchを当てることにします。GemのソースをForkして修正してローカルインストールして・・・ ってやっても良いですが、小さい修正ならばrubyのオープンクラス特性を利用してMonkey Patchを当てたほうが早かったりします。

# ./monkey_patch/gruff/line.rb
class Gruff::Line
  def normalize(force=false)
    @maximum_value = [@maximum_value.to_f, @baseline_value.to_f].max
    super(force)
    @norm_baseline = (@baseline_value.to_f / @maximum_value.to_f) if @baseline_value
  end
end

Gemをrequireした後にこのMonkey Patchをrequireし、Gruff::Lineクラスにnormalize(force=true)メソッドを追加します。

・・・
require 'rubygems'
require 'gruff'
require './monkey_patch/gruff/line'
・・・

f:id:n_matsui:20120101221500p:plain

凡例を左揃えにする

gruffは凡例に表示する文字列の長さと描画領域の横幅を考慮して、凡例をオーバーラップしてくれます。これはこれですばらしいのですが、文字列の長さが(この例のように)大幅に異なる場合、なんだか気持ち悪い見た目になります。ついでに、このオーバーラップ計算を強制的にキャンセルしてみます。

凡例はGruff::Baseクラスのdraw_legendメソッドで描画されます。デフォルトのこのメソッドをundefし書き換えることにします。

# ./monkey_patch/gruff/base.rb
module Gruff
  class Base
    undef draw_legend
    def draw_legend
      return if @hide_legend

      @legend_labels = @data.collect {|item| item[DATA_LABEL_INDEX] }

      legend_square_width = @legend_box_size # small square with color of this item

      # May fix legend drawing problem at small sizes
      @d.font = @font if @font
      @d.pointsize = @legend_font_size

      label_widths = [[]] # Used to calculate line wrap
      @legend_labels.each do |label|
        metrics = @d.get_type_metrics(@base_image, label.to_s)
        label_width = metrics.width + legend_square_width * 2.7
        label_widths.last.push label_width

        if sum(label_widths.last) > (@raw_columns * 0.9)
          label_widths.push [label_widths.last.pop]
        end
      end

      current_x_offset =  50
      current_y_offset =  @hide_title ?
      @top_margin + title_margin :
        @top_margin + title_margin + @title_caps_height

      @legend_labels.each_with_index do |legend_label, index|

        # Draw label
        @d.fill = @font_color
        @d.font = @font if @font
        @d.pointsize = scale_fontsize(@legend_font_size)
        @d.stroke('transparent')
        @d.font_weight = NormalWeight
        @d.gravity = WestGravity
        @d = @d.annotate_scaled( @base_image,
                                 @raw_columns, 1.0,
                                 current_x_offset + (legend_square_width * 1.7), current_y_offset,
                                 legend_label.to_s, @scale)

        # Now draw box with color of this dataset
        @d = @d.stroke('transparent')
        @d = @d.fill @data[index][DATA_COLOR_INDEX]
        @d = @d.rectangle(current_x_offset,
                          current_y_offset - legend_square_width / 2.0,
                          current_x_offset + legend_square_width,
                          current_y_offset + legend_square_width / 2.0)

        @d.pointsize = @legend_font_size
        metrics = @d.get_type_metrics(@base_image, legend_label.to_s)
        current_string_offset = metrics.width + (legend_square_width * 2.7)

        line_height = [@legend_caps_height, legend_square_width].max + legend_margin
        current_y_offset += line_height
        @graph_top += line_height
        @graph_height = @graph_bottom - @graph_top
      end
      @color_index = 0
    end
  end
end

こいつをrequireすると

・・・
require 'rubygems'
require 'gruff'
require './monkey_patch/gruff/base'
require './monkey_patch/gruff/line'
・・・

凡例が左揃えになります。

f:id:n_matsui:20120101222548p:plain