]> projects.mako.cc - selectricity-live/blob - vendor/plugins/gruff/lib/gruff/base.rb
88cec94626994ec719409ca5b804e6d6ad0888d1
[selectricity-live] / vendor / plugins / gruff / lib / gruff / base.rb
1 #
2 # = Gruff. Graphs.
3 #
4 # Author:: Geoffrey Grosenbach boss@topfunky.com
5 #
6 # Originally Created:: October 23, 2005
7 #
8 # Extra thanks to Tim Hunter for writing RMagick, 
9 # and also contributions by
10 # Jarkko Laine, Mike Perham, Andreas Schwarz, 
11 # Alun Eyre, Guillaume Theoret, David Stokar, 
12 # Paul Rogers, Dave Woodward, Frank Oxener,
13 # Kevin Clark, Cies Breijs, Richard Cowin,
14 # and a cast of thousands.
15 #
16
17 require 'rubygems'
18 require 'RMagick'
19 require File.dirname(__FILE__) + '/deprecated'
20
21 module Gruff
22   
23   VERSION = '0.2.9'
24   
25   class Base
26   
27     include Magick
28     include Deprecated
29
30     # Draw extra lines showing where the margins and text centers are
31     DEBUG = false
32
33     # Used for navigating the array of data to plot
34     DATA_LABEL_INDEX = 0
35     DATA_VALUES_INDEX = 1
36     DATA_COLOR_INDEX = 2
37
38     # Blank space around the edges of the graph
39     TOP_MARGIN = BOTTOM_MARGIN = RIGHT_MARGIN = LEFT_MARGIN = 20.0
40     
41     # Space around text elements. Mostly used for vertical spacing
42     LEGEND_MARGIN = TITLE_MARGIN = LABEL_MARGIN = 10.0
43
44     DEFAULT_TARGET_WIDTH = 800
45     
46     # A hash of names for the individual columns, where the key is the array index for the column this label represents.
47     #
48     # Not all columns need to be named.
49     #
50     # Example: 0 => 2005, 3 => 2006, 5 => 2007, 7 => 2008
51     attr_accessor :labels
52
53     # Used internally for spacing.
54     #
55     # By default, labels are centered over the point they represent.
56     attr_accessor :center_labels_over_point
57
58     # Used internally for horizontal graph types.
59     attr_accessor :has_left_labels
60
61     # A label for the bottom of the graph
62     attr_accessor :x_axis_label
63     
64     # A label for the left side of the graph
65     attr_accessor :y_axis_label
66
67     # attr_accessor :x_axis_increment
68     
69     # Manually set increment of the horizontal marking lines
70     attr_accessor :y_axis_increment
71
72     # Get or set the list of colors that will be used to draw the bars or lines.
73     attr_accessor :colors
74
75     # The large title of the graph displayed at the top
76     attr_accessor :title
77
78     # Font used for titles, labels, etc. Works best if you provide the full path to the TTF font file.
79     # RMagick must be built with the Freetype libraries for this to work properly.
80     #
81     # Tries to find Bitstream Vera (Vera.ttf) in the location specified by
82     # ENV['MAGICK_FONT_PATH']. Uses default RMagick font otherwise.
83     #
84     # The font= method below fulfills the role of the writer, so we only need 
85     # a reader here.
86     attr_reader :font
87
88     attr_accessor :font_color
89
90     # Hide various elements
91     attr_accessor :hide_line_markers, :hide_legend, :hide_title, :hide_line_numbers
92
93     # Message shown when there is no data. Fits up to 20 characters. Defaults to "No Data."
94     attr_accessor :no_data_message
95
96     # The font size of the large title at the top of the graph
97     attr_accessor :title_font_size
98
99     # Optionally set the size of the font. Based on an 800x600px graph. Default is 20.
100     #
101     # Will be scaled down if graph is smaller than 800px wide.
102     attr_accessor :legend_font_size
103
104     # The font size of the labels around the graph
105     attr_accessor :marker_font_size
106     
107     # The color of the auxiliary lines
108     attr_accessor :marker_color
109
110     # The number of horizontal lines shown for reference
111     attr_accessor :marker_count
112     
113
114     # You can manually set a minimum value instead of having the values guessed for you.
115     #
116     # Set it after you have given all your data to the graph object.
117     attr_accessor :minimum_value
118
119     # You can manually set a maximum value, such as a percentage-based graph that always goes to 100.
120     #
121     # If you use this, you must set it after you have given all your data to the graph object.
122     attr_accessor :maximum_value
123     
124     # Set to false if you don't want the data to be sorted with largest avg values at the back.
125     attr_accessor :sort
126     
127     # Experimental
128     attr_accessor :additional_line_values
129     
130     # Experimental
131     attr_accessor :stacked
132     
133     
134     # Optionally set the size of the colored box by each item in the legend. Default is 20.0
135     #
136     # Will be scaled down if graph is smaller than 800px wide.
137     attr_accessor :legend_box_size
138
139
140     # If one numerical argument is given, the graph is drawn at 4/3 ratio according to the given width (800 results in 800x600, 400 gives 400x300, etc.).
141     #
142     # Or, send a geometry string for other ratios ('800x400', '400x225'). 
143     #
144     # Looks for Bitstream Vera as the default font. Expects an environment var of MAGICK_FONT_PATH to be set. (Uses RMagick's default font otherwise.)
145     def initialize(target_width=DEFAULT_TARGET_WIDTH)
146
147       if not Numeric === target_width
148         geometric_width, geometric_height = target_width.split('x')
149         @columns = geometric_width.to_f
150         @rows = geometric_height.to_f
151       else
152         @columns = target_width.to_f
153         @rows = target_width.to_f * 0.75        
154       end
155
156       initialize_ivars
157
158       reset_themes
159       theme_keynote
160     end
161
162     ##
163     # Set instance variables for this object.
164     #
165     # Subclasses can override this, call super, then set values separately.
166     #
167     # This makes it possible to set defaults in a subclass but still allow
168     # developers to change this values in their program.
169     
170     def initialize_ivars
171       # Internal for calculations
172       @raw_columns = 800.0
173       @raw_rows = 800.0 * (@rows/@columns)
174       @column_count = 0
175       @marker_count = nil
176       @maximum_value = @minimum_value = nil
177       @has_data = false
178       @data = Array.new
179       @labels = Hash.new
180       @labels_seen = Hash.new
181       @sort = true
182       @title = nil
183
184       @scale = @columns / @raw_columns
185
186       vera_font_path = File.expand_path('Vera.ttf', ENV['MAGICK_FONT_PATH'])
187       @font = File.exists?(vera_font_path) ? vera_font_path : nil
188
189       @marker_font_size = 21.0
190       @legend_font_size = 20.0
191       @title_font_size = 36.0
192       
193       @legend_box_size = 20.0
194
195       @no_data_message = "No Data"
196
197       @hide_line_markers = @hide_legend = @hide_title = @hide_line_numbers = false
198       @center_labels_over_point = true
199       @has_left_labels = false
200
201       @additional_line_values = []      
202       @additional_line_colors = []
203       @theme_options = {}
204       
205       @x_axis_label = @y_axis_label = nil
206       @y_axis_increment = nil
207       @stacked = nil
208       @norm_data = nil
209     end
210     
211     def font=(font_path)
212       @font = font_path
213       @d.font = @font
214     end
215
216     # Add a color to the list of available colors for lines.
217     #
218     # Example: 
219     #  add_color('#c0e9d3')
220     def add_color(colorname)
221       @colors << colorname
222     end
223
224
225     # Replace the entire color list with a new array of colors. You need to have one more color
226     # than the number of datasets you intend to draw. Also aliased as the colors= setter method.
227     #
228     # Example: 
229     #  replace_colors('#cc99cc', '#d9e043', '#34d8a2')
230     def replace_colors(color_list=[])
231       @colors = color_list
232     end
233
234
235     # You can set a theme manually. Assign a hash to this method before you send your data.
236     #
237     #  graph.theme = {
238     #    :colors => %w(orange purple green white red),
239     #    :marker_color => 'blue',
240     #    :background_colors => %w(black grey)
241     #  }
242     #
243     # :background_image => 'squirrel.png' is also possible.
244     #
245     # (Or hopefully something better looking than that.)
246     #
247     def theme=(options)
248       reset_themes()
249       
250       defaults = {
251         :colors => ['black', 'white'],
252         :additional_line_colors => [],
253         :marker_color => 'white',
254         :font_color => 'black',
255         :background_colors => nil,
256         :background_image => nil
257       }
258       @theme_options = defaults.merge options
259
260       @colors = @theme_options[:colors]
261       @marker_color = @theme_options[:marker_color]
262       @font_color = @theme_options[:font_color] || @marker_color
263       @additional_line_colors = @theme_options[:additional_line_colors]
264       
265       render_background
266     end
267     
268     # A color scheme similar to the popular presentation software.
269     def theme_keynote
270       # Colors
271       @blue = '#6886B4'
272       @yellow = '#FDD84E'
273       @green = '#72AE6E'
274       @red = '#D1695E'
275       @purple = '#8A6EAF'
276       @orange = '#EFAA43'
277       @white = 'white'
278       @colors = [@yellow, @blue, @green, @red, @purple, @orange, @white]
279
280       self.theme = {
281         :colors => @colors,
282         :marker_color => 'white',
283         :font_color => 'white',
284         :background_colors => ['black', '#4a465a']
285       }
286     end
287     
288     # A color scheme plucked from the colors on the popular usability blog.
289     def theme_37signals
290       # Colors
291       @green = '#339933'
292       @purple = '#cc99cc'
293       @blue = '#336699'
294       @yellow = '#FFF804'
295       @red = '#ff0000'
296       @orange = '#cf5910'
297       @black = 'black'
298       @colors = [@yellow, @blue, @green, @red, @purple, @orange, @black]
299
300       self.theme = {
301         :colors => @colors,
302         :marker_color => 'black',
303         :font_color => 'black',
304         :background_colors => ['#d1edf5', 'white']
305       }
306     end
307
308     # A color scheme from the colors used on the 2005 Rails keynote presentation at RubyConf.
309     def theme_rails_keynote
310       # Colors
311       @green = '#00ff00'
312       @grey = '#333333'
313       @orange = '#ff5d00'
314       @red = '#f61100'
315       @white = 'white'
316       @light_grey = '#999999'
317       @black = 'black'
318       @colors = [@green, @grey, @orange, @red, @white, @light_grey, @black]
319       
320       self.theme = {
321         :colors => @colors,
322         :marker_color => 'white',
323         :font_color => 'white',
324         :background_colors => ['#0083a3', '#0083a3']
325       }
326     end
327
328     # A color scheme similar to that used on the popular podcast site.
329     def theme_odeo
330       # Colors
331       @grey = '#202020'
332       @white = 'white'
333       @dark_pink = '#a21764'
334       @green = '#8ab438'
335       @light_grey = '#999999'
336       @dark_blue = '#3a5b87'
337       @black = 'black'
338       @colors = [@grey, @white, @dark_blue, @dark_pink, @green, @light_grey, @black]
339       
340       self.theme = {
341         :colors => @colors,
342         :marker_color => 'white',
343         :font_color => 'white',
344         :background_colors => ['#ff47a4', '#ff1f81']
345       }
346     end
347
348     # A pastel theme
349     def theme_pastel
350       # Colors
351       @colors = [
352           '#a9dada', # blue
353           '#aedaa9', # green
354           '#daaea9', # peach
355           '#dadaa9', # yellow
356           '#a9a9da', # dk purple
357           '#daaeda', # purple
358           '#dadada' # grey
359         ]
360       
361       self.theme = {
362         :colors => @colors,
363         :marker_color => '#aea9a9', # Grey
364         :font_color => 'black',
365         :background_colors => 'white'
366       }
367     end
368
369     # A greyscale theme
370     def theme_greyscale
371       # Colors
372       @colors = [
373           '#282828', # 
374           '#383838', # 
375           '#686868', # 
376           '#989898', # 
377           '#c8c8c8', # 
378           '#e8e8e8', # 
379         ]
380       
381       self.theme = {
382         :colors => @colors,
383         :marker_color => '#aea9a9', # Grey
384         :font_color => 'black',
385         :background_colors => 'white'
386       }
387     end
388
389
390     # Parameters are an array where the first element is the name of the dataset
391     # and the value is an array of values to plot.
392     #
393     # Can be called multiple times with different datasets for a multi-valued graph.
394     #
395     # If the color argument is nil, the next color from the default theme will be used.
396     #
397     # NOTE: If you want to use a preset theme, you must set it before calling data().
398     #
399     # Example:
400     #
401     #  data("Bart S.", [95, 45, 78, 89, 88, 76], '#ffcc00')
402     #
403     def data(name, data_points=[], color=nil)
404       data_points = Array(data_points) # make sure it's an array
405       @data << [name, data_points, (color || increment_color)]
406       # Set column count if this is larger than previous counts
407       @column_count = (data_points.length > @column_count) ? data_points.length : @column_count
408
409       # Pre-normalize
410       data_points.each_with_index do |data_point, index|
411         next if data_point.nil?
412         
413         # Setup max/min so spread starts at the low end of the data points
414         if @maximum_value.nil? && @minimum_value.nil?
415           @maximum_value = @minimum_value = data_point
416         end
417
418         # TODO Doesn't work with stacked bar graphs
419         # Original: @maximum_value = larger_than_max?(data_point, index) ? max(data_point, index) : @maximum_value
420         @maximum_value = larger_than_max?(data_point) ? data_point : @maximum_value
421         @has_data = true if @maximum_value > 0
422         
423         @minimum_value = less_than_min?(data_point) ? data_point : @minimum_value
424           @has_data = true if @minimum_value < 0
425       end
426     end
427
428     # Writes the graph to a file. Defaults to 'graph.png'
429     #
430     # Example: write('graphs/my_pretty_graph.png')
431     def write(filename="graph.png")
432       draw()
433       @base_image.write(filename)
434     end
435
436     # Return the graph as a rendered binary blob.
437     def to_blob(fileformat='PNG')
438       draw()
439       return @base_image.to_blob do
440          self.format = fileformat
441       end
442     end
443
444 protected
445
446     # Overridden by subclasses to do the actual plotting of the graph.
447     #
448     # Subclasses should start by calling super() for this method.
449     def draw
450       make_stacked if @stacked
451       setup_drawing
452       
453       debug {
454         # Outer margin
455         @d.rectangle( LEFT_MARGIN, TOP_MARGIN, 
456                             @raw_columns - RIGHT_MARGIN, @raw_rows - BOTTOM_MARGIN)
457         # Graph area box
458         @d.rectangle( @graph_left, @graph_top, @graph_right, @graph_bottom)
459       }
460     end
461
462     ##
463     # Calculates size of drawable area and draws the decorations.
464     #
465     # * line markers
466     # * legend
467     # * title
468     
469     def setup_drawing
470       # Maybe should be done in one of the following functions for more granularity.
471       unless @has_data
472         draw_no_data()
473         return
474       end
475       
476       normalize()
477       setup_graph_measurements()
478       sort_norm_data() if @sort # Sort norm_data with avg largest values set first (for display)
479       
480       draw_legend()
481       draw_line_markers()
482       draw_axis_labels()
483       draw_title
484     end
485
486     # Make copy of data with values scaled between 0-100
487     def normalize(force=false)
488       if @norm_data.nil? || force
489         @norm_data = []
490         return unless @has_data
491                 
492         calculate_spread
493         
494         @data.each do |data_row|
495           norm_data_points = []
496           data_row[DATA_VALUES_INDEX].each do |data_point|
497             if data_point.nil?
498               norm_data_points << nil
499             else
500               norm_data_points << ((data_point.to_f - @minimum_value.to_f ) / @spread)
501             end
502           end
503           @norm_data << [data_row[DATA_LABEL_INDEX], norm_data_points, data_row[DATA_COLOR_INDEX]]
504         end
505       end
506     end
507
508     def calculate_spread
509       @spread = @maximum_value.to_f - @minimum_value.to_f
510       @spread = @spread > 0 ? @spread : 1
511     end
512
513     ##
514     # Calculates size of drawable area, general font dimensions, etc.
515     
516     def setup_graph_measurements
517       @marker_caps_height = calculate_caps_height(@marker_font_size)
518       @title_caps_height = calculate_caps_height(@title_font_size)
519       @legend_caps_height = calculate_caps_height(@legend_font_size)
520       
521       if @hide_line_markers
522         (@graph_left, 
523          @graph_right_margin, 
524          @graph_bottom_margin) = [LEFT_MARGIN, RIGHT_MARGIN, BOTTOM_MARGIN]
525       else
526         longest_left_label_width = 0
527         if @has_left_labels
528           longest_left_label_width =  calculate_width(@marker_font_size,
529                                       labels.values.inject('') { |value, memo| (value.to_s.length > memo.to_s.length) ? value : memo }) * 1.25
530         else
531           longest_left_label_width = calculate_width(@marker_font_size, 
532                           label(@maximum_value.to_f))
533         end
534       
535         # Shift graph if left line numbers are hidden
536         line_number_width = @hide_line_numbers && !@has_left_labels ? 
537                               0.0 : 
538                               (longest_left_label_width + LABEL_MARGIN * 2)
539
540         @graph_left = LEFT_MARGIN + 
541                       line_number_width + 
542                       (@y_axis_label.nil? ? 0.0 : @marker_caps_height + LABEL_MARGIN * 2)
543         # Make space for half the width of the rightmost column label.
544         # Might be greater than the number of columns if between-style bar markers are used.
545         last_label = @labels.keys.sort.last.to_i
546         extra_room_for_long_label = (last_label >= (@column_count-1) && @center_labels_over_point) ?
547           calculate_width(@marker_font_size, @labels[last_label])/2.0 :
548           0
549         @graph_right_margin =   RIGHT_MARGIN + extra_room_for_long_label
550                                 
551         @graph_bottom_margin =  BOTTOM_MARGIN + 
552                                 @marker_caps_height + LABEL_MARGIN
553       end
554
555       @graph_right = @raw_columns - @graph_right_margin
556       @graph_width = @raw_columns - @graph_left - @graph_right_margin
557
558       # When @hide title, leave a TITLE_MARGIN space for aesthetics.
559       # Same with @hide_legend
560       @graph_top = TOP_MARGIN + 
561                     (@hide_title ? TITLE_MARGIN : @title_caps_height + TITLE_MARGIN * 2) +
562                     (@hide_legend ? LEGEND_MARGIN : @legend_caps_height + LEGEND_MARGIN * 2)
563
564       @graph_bottom = @raw_rows - @graph_bottom_margin -
565                       (@x_axis_label.nil? ? 0.0 : @marker_caps_height + LABEL_MARGIN)
566       
567       @graph_height = @graph_bottom - @graph_top
568     end
569
570     # Draw the optional labels for the x axis and y axis.
571     def draw_axis_labels
572       unless @x_axis_label.nil?
573         # X Axis
574         # Centered vertically and horizontally by setting the
575         # height to 1.0 and the width to the width of the graph.
576         x_axis_label_y_coordinate = @graph_bottom + LABEL_MARGIN * 2 + @marker_caps_height
577
578         # TODO Center between graph area
579         @d.fill = @font_color
580         @d.font = @font if @font
581         @d.stroke('transparent')
582         @d.pointsize = scale_fontsize(@marker_font_size)
583         @d.gravity = NorthGravity
584         @d = @d.annotate_scaled( @base_image, 
585                           @raw_columns, 1.0, 
586                           0.0, x_axis_label_y_coordinate, 
587                           @x_axis_label, @scale)
588         debug { @d.line 0.0, x_axis_label_y_coordinate, @raw_columns, x_axis_label_y_coordinate }
589       end
590
591       unless @y_axis_label.nil?
592         # Y Axis, rotated vertically
593         @d.rotation = 90.0
594         @d.gravity = CenterGravity
595         @d = @d.annotate_scaled( @base_image, 
596                           1.0, @raw_rows,
597                           LEFT_MARGIN + @marker_caps_height / 2.0, 0.0, 
598                           @y_axis_label, @scale)
599         @d.rotation = -90.0
600       end
601     end
602
603     # Draws horizontal background lines and labels
604     def draw_line_markers
605       return if @hide_line_markers
606       
607       @d = @d.stroke_antialias false
608             
609       if @y_axis_increment.nil?
610         # Try to use a number of horizontal lines that will come out even.
611         #
612         # TODO Do the same for larger numbers...100, 75, 50, 25
613         if @marker_count.nil?
614           (3..7).each do |lines|
615             if @spread % lines == 0.0
616               @marker_count = lines
617               break
618             end
619           end
620           @marker_count ||= 4
621         end
622         @increment = (@spread > 0) ? significant(@spread / @marker_count) : 1
623       else
624         # TODO Make this work for negative values
625         @maximum_value = [@maximum_value.ceil, @y_axis_increment].max
626         @minimum_value = @minimum_value.floor
627         calculate_spread
628         normalize(true)
629         
630         @marker_count = (@spread / @y_axis_increment).to_i
631         @increment = @y_axis_increment
632       end
633       @increment_scaled = @graph_height.to_f / (@spread / @increment)
634
635       # Draw horizontal line markers and annotate with numbers
636       (0..@marker_count).each do |index|
637         y = @graph_top + @graph_height - index.to_f * @increment_scaled
638         
639         @d = @d.stroke(@marker_color)
640         @d = @d.stroke_width 1
641         @d = @d.line(@graph_left, y, @graph_right, y)
642
643         marker_label = index * @increment + @minimum_value.to_f
644
645         unless @hide_line_numbers
646           @d.fill = @font_color
647           @d.font = @font if @font
648           @d.stroke('transparent')
649           @d.pointsize = scale_fontsize(@marker_font_size)
650           @d.gravity = EastGravity
651         
652           # Vertically center with 1.0 for the height
653           @d = @d.annotate_scaled( @base_image, 
654                             @graph_left - LABEL_MARGIN, 1.0,
655                             0.0, y,
656                             label(marker_label), @scale)
657         end
658       end
659       
660       # # Submitted by a contibutor...the utility escapes me
661       # i = 0
662       # @additional_line_values.each do |value|
663       #   @increment_scaled = @graph_height.to_f / (@maximum_value.to_f / value)
664       # 
665       #   y = @graph_top + @graph_height - @increment_scaled
666       # 
667       #   @d = @d.stroke(@additional_line_colors[i])
668       #   @d = @d.line(@graph_left, y, @graph_right, y)
669       # 
670       # 
671       #   @d.fill = @additional_line_colors[i]
672       #   @d.font = @font if @font
673       #   @d.stroke('transparent')
674       #   @d.pointsize = scale_fontsize(@marker_font_size)
675       #   @d.gravity = EastGravity
676       #   @d = @d.annotate_scaled( @base_image, 
677       #                     100, 20,
678       #                     -10, y - (@marker_font_size/2.0), 
679       #                     "", @scale)
680       #   i += 1   
681       # end
682       
683       @d = @d.stroke_antialias true
684     end
685
686     # Draws a legend with the names of the datasets 
687     # matched to the colors used to draw them.
688     def draw_legend
689       return if @hide_legend
690
691       @legend_labels = @data.collect {|item| item[DATA_LABEL_INDEX] }
692
693       legend_square_width = @legend_box_size # small square with color of this item
694
695       # May fix legend drawing problem at small sizes
696       @d.font = @font if @font
697       @d.pointsize = @legend_font_size
698
699       metrics = @d.get_type_metrics(@base_image, @legend_labels.join(''))
700       legend_text_width = metrics.width
701       legend_width = legend_text_width + 
702                     (@legend_labels.length * legend_square_width * 2.7)
703       legend_left = (@raw_columns - legend_width) / 2
704       legend_increment = legend_width / @legend_labels.length.to_f
705
706       current_x_offset = legend_left
707       current_y_offset =  @hide_title ? 
708                           TOP_MARGIN + LEGEND_MARGIN : 
709                           TOP_MARGIN + 
710                           TITLE_MARGIN + @title_caps_height +
711                           LEGEND_MARGIN
712
713       debug { @d.line 0.0, current_y_offset, @raw_columns, current_y_offset }
714                                                     
715       @legend_labels.each_with_index do |legend_label, index|        
716
717         # Draw label
718         @d.fill = @font_color
719         @d.font = @font if @font
720         @d.pointsize = scale_fontsize(@legend_font_size)
721         @d.stroke('transparent')
722         @d.font_weight = NormalWeight
723         @d.gravity = WestGravity
724         @d = @d.annotate_scaled( @base_image, 
725                           @raw_columns, 1.0,
726                           current_x_offset + (legend_square_width * 1.7), current_y_offset, 
727                           legend_label.to_s, @scale)
728         
729         # Now draw box with color of this dataset
730         @d = @d.stroke('transparent')
731         @d = @d.fill @data[index][DATA_COLOR_INDEX]
732         @d = @d.rectangle(current_x_offset, 
733                           current_y_offset - legend_square_width / 2.0, 
734                           current_x_offset + legend_square_width, 
735                           current_y_offset + legend_square_width / 2.0)
736
737         @d.pointsize = @legend_font_size
738         metrics = @d.get_type_metrics(@base_image, legend_label.to_s)
739         current_string_offset = metrics.width + (legend_square_width * 2.7)
740         current_x_offset += current_string_offset
741       end
742       @color_index = 0
743     end
744
745     def draw_title
746       return if (@hide_title || @title.nil?)
747       
748       @d.fill = @font_color
749       @d.font = @font if @font
750       @d.stroke('transparent')
751       @d.pointsize = scale_fontsize(@title_font_size)
752       @d.font_weight = BoldWeight
753       @d.gravity = NorthGravity
754       @d = @d.annotate_scaled( @base_image, 
755                         @raw_columns, 1.0,
756                         0, TOP_MARGIN, 
757                         @title, @scale)
758     end
759
760     ##
761     # Draws column labels below graph, centered over x_offset
762     #
763     # TODO Allow WestGravity as an option
764     
765     def draw_label(x_offset, index)
766       return if @hide_line_markers
767
768       if !@labels[index].nil? && @labels_seen[index].nil?
769         y_offset = @graph_bottom + LABEL_MARGIN
770
771         @d.fill = @font_color
772         @d.font = @font if @font
773         @d.stroke('transparent')
774         @d.font_weight = NormalWeight
775         @d.pointsize = scale_fontsize(@marker_font_size)
776         @d.gravity = NorthGravity
777         @d = @d.annotate_scaled(@base_image,
778                                 1.0, 1.0,
779                                 x_offset, y_offset,
780                                 @labels[index], @scale)
781         @labels_seen[index] = 1
782         debug { @d.line 0.0, y_offset, @raw_columns, y_offset }
783       end
784     end
785
786     def draw_no_data
787         @d.fill = @font_color
788         @d.font = @font if @font
789         @d.stroke('transparent')
790         @d.font_weight = NormalWeight
791         @d.pointsize = scale_fontsize(80)
792         @d.gravity = CenterGravity
793         @d = @d.annotate_scaled( @base_image, 
794                         @raw_columns, @raw_rows/2.0,
795                         0, 10, 
796                         @no_data_message, @scale)
797     end
798
799     ##
800     # Finds the best background to render based on the provided theme options.
801     #
802     # Creates a @base_image to draw on.
803     #
804     def render_background
805       case @theme_options[:background_colors]
806       when Array
807         @base_image = render_gradiated_background(*@theme_options[:background_colors])
808       when String
809         @base_image = render_solid_background(@theme_options[:background_colors])
810       else
811         @base_image = render_image_background(*@theme_options[:background_image])
812       end
813     end
814
815     ##
816     # Make a new image at the current size with a solid +color+.
817     
818     def render_solid_background(color)
819       Image.new(@columns, @rows) {
820         self.background_color = color
821       }
822     end
823
824     # Use with a theme definition method to draw a gradiated background.
825     def render_gradiated_background(top_color, bottom_color)
826       Image.new(@columns, @rows, 
827           GradientFill.new(0, 0, 100, 0, top_color, bottom_color))
828     end
829
830     # Use with a theme to use an image (800x600 original) background.
831     def render_image_background(image_path)
832       image = Image.read(image_path)
833       if @scale != 1.0
834         image[0].resize!(@scale) # TODO Resize with new scale (crop if necessary for wide graph)
835       end
836       image[0]
837     end
838     
839     # Use with a theme to make a transparent background
840     def render_transparent_background
841       Image.new(@columns, @rows) do
842         self.background_color = 'transparent'
843       end
844     end
845
846     def reset_themes
847       @color_index = 0
848       @labels_seen = {}
849       @theme_options = {}
850       
851       @d = Draw.new
852       # Scale down from 800x600 used to calculate drawing.
853       @d = @d.scale(@scale, @scale)
854     end
855
856     def scale(value)
857       value * @scale
858     end
859     
860     # Return a comparable fontsize for the current graph.
861     def scale_fontsize(value)
862       new_fontsize = value * @scale
863       # return new_fontsize < 10.0 ? 10.0 : new_fontsize
864       return new_fontsize
865     end
866
867     def clip_value_if_greater_than(value, max_value)
868       (value > max_value) ? max_value : value
869     end
870
871     # Overridden by subclasses such as stacked bar.
872     def larger_than_max?(data_point, index=0)
873       data_point > @maximum_value
874     end
875
876           def less_than_min?(data_point, index=0)
877       data_point < @minimum_value
878     end
879
880     ##
881     # Overridden by subclasses that need it.
882     def max(data_point, index)
883       data_point
884     end
885
886     ##
887     # Overridden by subclasses that need it.
888           def min(data_point, index)
889       data_point
890     end
891    
892     def significant(inc)
893       return 1.0 if inc == 0 # Keep from going into infinite loop
894       factor = 1.0
895       while (inc < 10)
896         inc *= 10
897         factor /= 10
898       end
899
900       while (inc > 100)
901         inc /= 10
902         factor *= 10
903       end
904
905       res = inc.floor * factor
906       if (res.to_i.to_f == res)
907         res.to_i
908       else
909         res
910       end
911     end
912
913     # Sort with largest overall summed value at front of array 
914     # so it shows up correctly in the drawn graph.
915     def sort_norm_data
916       @norm_data.sort! { |a,b| sums(b[1]) <=> sums(a[1]) }
917     end
918
919     def sums(data_set)
920       total_sum = 0
921       data_set.collect {|num| total_sum += num.to_f }
922       total_sum
923     end
924
925     ##
926     # Used by StackedBar and child classes.
927     #
928     # May need to be moved to the StackedBar class.
929     
930     def get_maximum_by_stack
931       # Get sum of each stack
932       max_hash = {}
933       @data.each do |data_set|
934         data_set[DATA_VALUES_INDEX].each_with_index do |data_point, i|
935           max_hash[i] = 0.0 unless max_hash[i]
936           max_hash[i] += data_point.to_f
937         end
938       end
939
940       # @maximum_value = 0
941       max_hash.keys.each do |key|
942         @maximum_value = max_hash[key] if max_hash[key] > @maximum_value
943       end
944       @minimum_value = 0
945     end
946
947     def make_stacked
948       stacked_values = Array.new(@column_count, 0)
949       @data.each do |value_set|
950         value_set[1].each_with_index do |value, index|
951           stacked_values[index] += value
952         end
953         value_set[1] = stacked_values.dup
954       end
955     end
956
957 private
958     
959     # Takes a block and draws it if DEBUG is true.
960     #
961     #   debug { @d.rectangle x1, y1, x2, y2 }
962     #
963     def debug
964       if DEBUG
965         @d = @d.fill 'transparent'
966         @d = @d.stroke 'turquoise'
967         @d = yield
968       end
969     end
970     
971     def increment_color
972       if @color_index == 0
973         @color_index += 1
974         return @colors[0]
975       else
976         if @color_index < @colors.length
977           @color_index += 1
978           return @colors[@color_index - 1]
979         else
980           # Start over
981           @color_index = 0
982           return @colors[-1]
983         end
984       end
985     end
986
987     ##
988     # Return a formatted string representing a number value that should be printed as a label.   
989
990     def label(value)      
991       if (@spread.to_f % @marker_count.to_f == 0) || !@y_axis_increment.nil?
992         return value.to_i.to_s
993       end
994       
995       if @spread > 10.0
996         sprintf("%0i", value)
997       elsif @spread >= 3.0
998         sprintf("%0.2f", value)
999       else
1000         value.to_s
1001       end
1002     end
1003
1004     ##
1005     # Returns the height of the capital letter 'X' for the current font and size.
1006     #
1007     # Not scaled since it deals with dimensions that the regular 
1008     # scaling will handle.
1009     #
1010     def calculate_caps_height(font_size)
1011       @d.pointsize = font_size
1012       @d.get_type_metrics(@base_image, 'X').height
1013     end
1014
1015     ##
1016     # Returns the width of a string at this pointsize.
1017     #
1018     # Not scaled since it deals with dimensions that the regular 
1019     # scaling will handle.
1020     #    
1021     def calculate_width(font_size, text)
1022       @d.pointsize = font_size
1023       @d.get_type_metrics(@base_image, text.to_s).width
1024     end
1025
1026   end # Gruff::Base
1027   
1028   class IncorrectNumberOfDatasetsException < StandardError; end
1029           
1030 end # Gruff
1031
1032
1033 module Magick
1034   
1035   class Draw
1036     
1037     # Additional method since Draw.scale doesn't affect annotations.
1038     def annotate_scaled(img, width, height, x, y, text, scale)
1039       scaled_width = (width * scale) >= 1 ? (width * scale) : 1
1040       scaled_height = (height * scale) >= 1 ? (height * scale) : 1
1041       
1042       self.annotate( img, 
1043                       scaled_width, scaled_height,
1044                       x * scale, y * scale,
1045                       text)
1046     end
1047     
1048   end
1049   
1050 end # Magick
1051

Benjamin Mako Hill || Want to submit a patch?