merged in changes from live version
[selectricity-live] / vendor / plugins / sparklines / lib / sparklines.rb
1
2 require 'rubygems'
3 require 'RMagick'
4
5 =begin rdoc
6
7 A library for generating small unmarked graphs (sparklines).
8
9 Can be used to write an image to a file or make a web service with Rails or other Ruby CGI apps.
10
11 Idea and much of the outline for the source lifted directly from {Joe Gregorio's Python Sparklines web service script}[http://bitworking.org/projects/sparklines].
12
13 Requires the RMagick image library.
14
15 ==Authors
16
17 {Dan Nugent}[mailto:nugend@gmail.com] Original port from Python Sparklines library.
18
19 {Geoffrey Grosenbach}[mailto:boss@topfunky.com] -- http://nubyonrails.topfunky.com 
20 -- Conversion to module and further maintenance.
21
22 ==General Usage and Defaults
23
24 To use in a script:
25
26         require 'rubygems'
27         require 'sparklines'
28         Sparklines.plot([1,25,33,46,89,90,85,77,42], 
29                         :type => 'discrete', 
30                         :height => 20)
31
32 An image blob will be returned which you can print, write to STDOUT, etc.
33
34 For use with Ruby on Rails, see the sparklines plugin:
35
36   http://nubyonrails.com/pages/sparklines
37
38 In your view, call it like this:
39
40   <%= sparkline_tag [1,2,3,4,5,6] %>
41
42 Or specify details:
43
44   <%= sparkline_tag [1,2,3,4,5,6], 
45                     :type => 'discrete', 
46                     :height => 10, 
47                     :upper => 80, 
48                     :above_color => 'green', 
49                     :below_color => 'blue' %>
50
51 Graph types:
52
53  area
54  discrete
55  pie
56  smooth
57  bar
58  whisker
59
60 General Defaults:
61
62  :type              => 'smooth'
63  :height            => 14px
64  :upper             => 50
65  :above_color       => 'red'
66  :below_color       => 'grey'
67  :background_color  => 'white'
68  :line_color        => 'lightgrey'
69
70 ==License
71
72 Licensed under the MIT license.
73
74 =end
75 class Sparklines
76
77   VERSION = '0.4.1'
78
79   @@label_margin = 5.0
80   @@pointsize = 10.0
81
82   class << self
83
84     # Does the actual plotting of the graph. 
85     # Calls the appropriate subclass based on the :type argument. 
86     # Defaults to 'smooth'
87     def plot(data=[], options={})
88       defaults = {
89         :type => 'smooth',
90         :height => 14,
91         :upper => 50,
92         :diameter => 20,
93         :step => 2,
94         :line_color => 'lightgrey',
95
96         :above_color => 'red',
97         :below_color => 'grey',
98         :background_color => 'white',
99         :share_color => 'red',
100         :remain_color => 'lightgrey',
101         :min_color => 'blue',
102         :max_color => 'green',  
103         :last_color => 'red',                
104
105         :has_min => false,
106         :has_max => false,
107         :has_last => false,
108
109         :label => nil
110       }
111
112       # HACK for HashWithIndifferentAccess
113       options_sym = Hash.new
114       options.keys.each do |key|
115         options_sym[key.to_sym] = options[key]
116       end
117
118       options_sym  = defaults.merge(options_sym)
119         
120       # Call the appropriate method for actual plotting.
121       sparkline = self.new(data, options_sym)
122       if %w(area bar pie smooth discrete whisker).include? options_sym[:type]
123         sparkline.send options_sym[:type]
124       else
125         sparkline.plot_error options_sym
126       end
127     end
128
129     # Writes a graph to disk with the specified filename, or "sparklines.png"
130     def plot_to_file(filename="sparklines.png", data=[], options={})
131       File.open( filename, 'wb' ) do |png|
132         png << self.plot( data, options)
133       end
134     end
135
136   end # class methods
137
138   def initialize(data=[], options={})
139     @data = Array(data)
140     @options = options
141     normalize_data
142   end
143
144   ##
145   # Creates a continuous area sparkline. Relevant options.
146   #
147   # :step - An integer that determines the distance between each point on the sparkline.  Defaults to 2.
148   #
149   # :height - An integer that determines what the height of the sparkline will be.  Defaults to 14
150   #
151   # :upper - An integer that determines the threshold for colorization purposes.  Any value less than upper will be colored using below_color, anything above and equal to upper will use above_color.  Defaults to 50.
152   #
153   # :has_min - Determines whether a dot will be drawn at the lowest value or not.  Defaults to false.
154   #
155   # :has_max - Determines whether a dot will be drawn at the highest value or not.  Defaults to false.
156   #
157   # :has_last - Determines whether a dot will be drawn at the last value or not.  Defaults to false.
158   #
159   # :min_color - A string or color code representing the color that the dot drawn at the smallest value will be displayed as.  Defaults to blue.
160   #
161   # :max_color - A string or color code representing the color that the dot drawn at the largest value will be displayed as.  Defaults to green.
162   #
163   # :last_color - A string or color code representing the color that the dot drawn at the last value will be displayed as.  Defaults to red.
164   #
165   # :above_color - A string or color code representing the color to draw values above or equal the upper value.  Defaults to red.
166   #
167   # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
168
169   def area
170
171     step = @options[:step].to_i
172     height = @options[:height].to_i
173     background_color = @options[:background_color]
174     
175     create_canvas((@norm_data.size - 1) * step + 4, height, background_color)
176
177     upper = @options[:upper].to_i
178
179     has_min = @options[:has_min]
180     has_max = @options[:has_max]
181     has_last = @options[:has_last]
182
183     min_color = @options[:min_color]
184     max_color = @options[:max_color]
185     last_color = @options[:last_color]
186     below_color = @options[:below_color]
187     above_color = @options[:above_color]
188
189
190     coords = [[0,(height - 3 - upper/(101.0/(height-4)))]]
191     i=0
192     @norm_data.each do |r|
193         coords.push [(2 + i), (height - 3 - r/(101.0/(height-4)))]
194         i += step
195     end
196     coords.push [(@norm_data.size - 1) * step + 4, (height - 3 - upper/(101.0/(height-4)))]
197
198     # TODO Refactor! Should take a block and do both.
199     #
200     # Block off the bottom half of the image and draw the sparkline
201     @draw.fill(above_color)
202     @draw.define_clip_path('top') do
203       @draw.rectangle(0,0,(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
204     end
205     @draw.clip_path('top')
206     @draw.polygon *coords.flatten
207
208     # Block off the top half of the image and draw the sparkline
209     @draw.fill(below_color)
210     @draw.define_clip_path('bottom') do
211       @draw.rectangle(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,height)
212     end
213     @draw.clip_path('bottom')
214     @draw.polygon *coords.flatten
215
216     # The sparkline looks kinda nasty if either the above_color or below_color gets the center line
217     @draw.fill('black')
218     @draw.line(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
219
220     # After the parts have been masked, we need to let the whole canvas be drawable again
221     # so a max dot can be displayed
222     @draw.define_clip_path('all') do
223       @draw.rectangle(0,0,@canvas.columns,@canvas.rows)
224     end
225     @draw.clip_path('all')
226         
227     drawbox(coords[@norm_data.index(@norm_data.min)+1], 1, min_color) if has_min == true
228     drawbox(coords[@norm_data.index(@norm_data.max)+1], 1, max_color) if has_max == true
229     
230     drawbox(coords[-2], 1, last_color) if has_last == true
231
232     @draw.draw(@canvas)
233     @canvas.to_blob
234   end
235
236   ##
237   # A bar graph.
238
239   def bar
240     step = @options[:step].to_i
241     height = @options[:height].to_f
242     background_color = @options[:background_color]
243
244     create_canvas(@norm_data.length * step + 2, height, background_color)
245     
246     upper = @options[:upper].to_i
247     below_color = @options[:below_color]
248     above_color = @options[:above_color]
249
250     i = 1
251     @norm_data.each_with_index do |r, index|
252       color = (r >= upper) ? above_color : below_color
253       @draw.stroke('transparent')
254       @draw.fill(color)
255       @draw.rectangle( i, @canvas.rows, 
256             i + step - 2, @canvas.rows - ( (r / @maximum_value) * @canvas.rows) )
257       i += step
258     end
259
260     @draw.draw(@canvas)
261     @canvas.to_blob
262   end
263
264
265   ##
266   # Creates a discretized sparkline
267   #
268   # :height - An integer that determines what the height of the sparkline will be.  Defaults to 14
269   #
270   # :upper - An integer that determines the threshold for colorization purposes.  Any value less than upper will be colored using below_color, anything above and equal to upper will use above_color.  Defaults to 50.
271   #
272   # :above_color - A string or color code representing the color to draw values above or equal the upper value.  Defaults to red.
273   #
274   # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
275
276   def discrete
277
278     height = @options[:height].to_i
279     upper = @options[:upper].to_i
280     background_color = @options[:background_color]
281     step = @options[:step].to_i
282         
283     create_canvas(@norm_data.size * step - 1, height, background_color)
284     
285     below_color = @options[:below_color]
286     above_color = @options[:above_color]
287
288     i = 0
289     @norm_data.each do |r|
290         color = (r >= upper) ? above_color : below_color
291         @draw.stroke(color)
292         @draw.line(i, (@canvas.rows - r/(101.0/(height-4))-4).to_i,
293                   i, (@canvas.rows - r/(101.0/(height-4))).to_i)
294         i += step
295     end
296
297     @draw.draw(@canvas)
298     @canvas.to_blob
299   end
300
301
302   ##
303   # Creates a pie-chart sparkline
304   #
305   # :diameter - An integer that determines what the size of the sparkline will be.  Defaults to 20
306   #
307   # :share_color - A string or color code representing the color to draw the share of the pie represented by percent.  Defaults to red.
308   #
309   # :remain_color - A string or color code representing the color to draw the pie not taken by the share color. Defaults to lightgrey.
310
311   def pie
312     diameter = @options[:diameter].to_i
313     background_color = @options[:background_color]
314
315     create_canvas(diameter, diameter, background_color)
316     
317     share_color = @options[:share_color]
318     remain_color = @options[:remain_color]
319     percent = @norm_data[0]
320     
321     # Adjust the radius so there's some edge left in the pie
322     r = diameter/2.0 - 2
323     @draw.fill(remain_color)
324     @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
325     @draw.fill(share_color)
326
327     # Special exceptions
328     if percent == 0
329         # For 0% return blank
330         @draw.draw(@canvas)
331         return @canvas.to_blob
332     elsif percent == 100
333         # For 100% just draw a full circle
334         @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
335         @draw.draw(@canvas)
336         return @canvas.to_blob
337     end
338
339     # Okay, this part is as confusing as hell, so pay attention:
340     # This line determines the horizontal portion of the point on the circle where the X-Axis
341     # should end.  It's caculated by taking the center of the on-image circle and adding that
342     # to the radius multiplied by the formula for determinig the point on a unit circle that a
343     # angle corresponds to.  3.6 * percent gives us that angle, but it's in degrees, so we need to
344     # convert, hence the muliplication by Pi over 180
345     arc_end_x = r + 2 + (r * Math.cos((3.6 * percent)*(Math::PI/180)))
346
347     # The same goes for here, except it's the vertical point instead of the horizontal one
348     arc_end_y = r + 2 + (r * Math.sin((3.6 * percent)*(Math::PI/180)))
349
350     # Because the SVG path format is seriously screwy, we need to set the large-arc-flag to 1
351     # if the angle of an arc is greater than 180 degrees.  I have no idea why this is, but it is.
352     percent > 50? large_arc_flag = 1: large_arc_flag = 0
353
354     # This is also confusing
355     # M tells us to move to an absolute point on the image.  We're moving to the center of the pie
356     # h tells us to move to a relative point.  We're moving to the right edge of the circle.
357     # A tells us to start an absolute elliptical arc.  The first two values are the radii of the ellipse
358     # the third value is the x-axis-rotation (how to rotate the ellipse if we wanted to [could have some fun
359     # with randomizing that maybe), the fourth value is our large-arc-flag, the fifth is the sweep-flag,
360     # (again, confusing), the sixth and seventh values are the end point of the arc which we calculated previously
361     # More info on the SVG path string format at: http://www.w3.org/TR/SVG/paths.html
362     path = "M#{r + 2},#{r + 2} h#{r} A#{r},#{r} 0 #{large_arc_flag},1 #{arc_end_x},#{arc_end_y} z"
363     @draw.path(path)
364
365     @draw.draw(@canvas)
366     @canvas.to_blob
367   end
368
369
370   ##
371   # Creates a smooth sparkline.
372   #
373   # :step - An integer that determines the distance between each point on the sparkline.  Defaults to 2.
374   #
375   # :height - An integer that determines what the height of the sparkline will be.  Defaults to 14
376   #
377   # :has_min - Determines whether a dot will be drawn at the lowest value or not.  Defaults to false.
378   #
379   # :has_max - Determines whether a dot will be drawn at the highest value or not.  Defaults to false.
380   #
381   # :has_last - Determines whether a dot will be drawn at the last value or not.  Defaults to false.
382   #
383   # :min_color - A string or color code representing the color that the dot drawn at the smallest value will be displayed as.  Defaults to blue.
384   #
385   # :max_color - A string or color code representing the color that the dot drawn at the largest value will be displayed as.  Defaults to green.
386   #
387   # :last_color - A string or color code representing the color that the dot drawn at the last value will be displayed as.  Defaults to red.
388
389   def smooth
390
391     step = @options[:step].to_i
392     height = @options[:height].to_i
393     background_color = @options[:background_color]
394
395     create_canvas((@norm_data.size - 1) * step + 4, height, background_color)
396     
397     min_color = @options[:min_color]
398     max_color = @options[:max_color]
399     last_color = @options[:last_color]
400     has_min = @options[:has_min]
401     has_max = @options[:has_max]
402     has_last = @options[:has_last]
403     line_color = @options[:line_color]
404
405     @draw.stroke(line_color)
406     coords = []
407     i=0
408     @norm_data.each do |r|
409       coords.push [ 2 + i, (height - 3 - r/(101.0/(height-4))) ]
410       i += step
411     end
412     
413     open_ended_polyline(coords)
414
415     drawbox(coords[@norm_data.index(@norm_data.min)], 2, min_color) if has_min == true
416
417     drawbox(coords[@norm_data.index(@norm_data.max)], 2, max_color) if has_max == true
418
419     drawbox(coords[-1], 2, last_color) if has_last == true
420
421     @draw.draw(@canvas)
422     @canvas.to_blob
423   end
424
425
426   ##
427   # Creates a whisker sparkline to track on/off type data. There are five states: 
428   # on, off, no value, exceptional on, exceptional off. On values create an up 
429   # whisker and off values create a down whisker. Exceptional values may be 
430   # colored differently than regular values to indicate, for example, a shut out.
431   # No value produces an empty row to indicate a tie.
432   # 
433   # * results - an array of integer values between -2 and 2. -2 is exceptional 
434   #   down, 1 is regular down, 0 is no value, 1 is up, and 2 is exceptional up.
435   # * options - a hash that takes parameters
436   #
437   # :height - height of the sparkline
438   #
439   # :whisker_color - the color of regular whiskers; defaults to black
440   #
441   # :exception_color - the color of exceptional whiskers; defaults to red
442   
443   def whisker
444
445     # step = @options[:step].to_i
446     height = @options[:height].to_i
447     background_color = @options[:background_color]
448
449     create_canvas((@data.size - 1) * 2, height, background_color)
450     
451     whisker_color = @options[:whisker_color] || 'black'
452     exception_color = @options[:exception_color] || 'red'
453
454     i = 0
455     @data.each do |r|
456       color = whisker_color
457
458       if ( (r == 2 || r == -2) && exception_color )
459         color = exception_color
460       end
461
462       y_mid_point = (r >= 1) ? (@canvas.rows/2.0 - 1).ceil : (@canvas.rows/2.0).floor
463
464       y_end_point = y_mid_point
465       if ( r > 0) 
466         y_end_point = 0
467       end
468
469       if ( r < 0 )
470         y_end_point = @canvas.rows
471       end
472
473       @draw.stroke( color )
474       @draw.line( i, y_mid_point, i, y_end_point )
475       i += 2
476     end
477
478     @draw.draw(@canvas)
479     @canvas.to_blob 
480   end
481
482   ##
483   # Draw the error Sparkline.
484
485   def plot_error(options={})
486     create_canvas(40, 15, 'white')
487
488     @draw.fill('red')
489     @draw.line(0,0,40,15)
490     @draw.line(0,15,40,0)
491
492     @draw.draw(@canvas)
493     @canvas.to_blob
494   end
495
496 private
497
498   def normalize_data
499     @maximum_value = @data.max
500     if @options[:type].to_s == 'pie'
501       @norm_data = @data
502     else
503       @norm_data = @data.map { |value| value = (value.to_f / @maximum_value) * 100.0 }
504     end
505   end
506
507   ##
508   # * :arr - an array of points (represented as two element arrays)
509   
510   def open_ended_polyline(arr)
511     0.upto(arr.length - 2) { |i|
512       @draw.line(arr[i][0], arr[i][1], arr[i+1][0], arr[i+1][1])
513     }
514   end
515
516   ##
517   # Create an image to draw on and a drawable to do the drawing with.
518   #
519   # TODO Refactor into smaller methods
520
521   def create_canvas(w, h, bkg_col)
522     @draw = Magick::Draw.new
523     @draw.pointsize = @@pointsize # TODO Use height
524     @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
525
526     # Make room for label and last value
527     unless @options[:label].nil?
528       @options[:has_last] = true
529       @label_width = calculate_width(@options[:label])
530       @data_last_width = calculate_width(@data.last)
531       # HACK The 7.0 is a severe hack. Must figure out correct spacing
532       @label_and_data_last_width = @label_width + @data_last_width + @@label_margin * 7.0
533       w += @label_and_data_last_width
534     end
535
536     @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
537     @canvas.format = "PNG"
538
539     # Draw label and last value
540     unless @options[:label].nil?
541       if ENV.has_key?('MAGICK_FONT_PATH')
542         vera_font_path = File.expand_path('Vera.ttf', ENV['MAGICK_FONT_PATH'])
543         @font = File.exists?(vera_font_path) ? vera_font_path : nil
544       else
545         @font = nil
546       end
547
548       @draw.fill = 'black'
549       @draw.font = @font if @font
550       @draw.gravity = Magick::WestGravity
551       @draw.annotate( @canvas, 
552                       @label_width, 1.0,
553                       w - @label_and_data_last_width + @@label_margin, h - calculate_caps_height/2.0,
554                       @options[:label])
555
556       @draw.fill = 'red'
557       @draw.annotate( @canvas, 
558                       @data_last_width, 1.0,
559                       w - @data_last_width - @@label_margin * 2.0, h - calculate_caps_height/2.0,
560                       @data.last.to_s)
561     end
562   end
563
564   ##
565   # Utility to draw a coloured box
566   # Centred on pt, offset off in each direction, fill color is col
567
568   def drawbox(pt, offset, color)
569     @draw.stroke 'transparent'
570     @draw.fill(color)
571     @draw.rectangle(pt[0]-offset, pt[1]-offset, pt[0]+offset, pt[1]+offset)
572   end
573
574   def calculate_width(text)
575     @draw.get_type_metrics(@canvas, text.to_s).width
576   end
577
578   def calculate_caps_height
579     @draw.get_type_metrics(@canvas, 'X').height
580   end
581
582 end

Benjamin Mako Hill || Want to submit a patch?