topfunky-sparklines 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. data/CHANGELOG +74 -0
  2. data/MIT-LICENSE +21 -0
  3. data/Manifest.txt +64 -0
  4. data/README.txt +20 -0
  5. data/Rakefile +22 -0
  6. data/lib/sparklines.rb +785 -0
  7. data/lib/sparklines_helper.rb +28 -0
  8. data/samples/area-high.png +0 -0
  9. data/samples/area.png +0 -0
  10. data/samples/discrete.png +0 -0
  11. data/samples/pie-large.png +0 -0
  12. data/samples/pie.png +0 -0
  13. data/samples/pie0.png +0 -0
  14. data/samples/pie1.png +0 -0
  15. data/samples/pie100.png +0 -0
  16. data/samples/pie45.png +0 -0
  17. data/samples/pie95.png +0 -0
  18. data/samples/pie99.png +0 -0
  19. data/samples/smooth-colored.png +0 -0
  20. data/samples/smooth.png +0 -0
  21. data/test/expected/area.png +0 -0
  22. data/test/expected/area_high.png +0 -0
  23. data/test/expected/area_min_max.png +0 -0
  24. data/test/expected/bar.png +0 -0
  25. data/test/expected/bar_extreme_values.png +0 -0
  26. data/test/expected/bar_string.png.png +0 -0
  27. data/test/expected/bar_tall.png +0 -0
  28. data/test/expected/bar_wide.png +0 -0
  29. data/test/expected/bullet_basic.png +0 -0
  30. data/test/expected/bullet_colorful.png +0 -0
  31. data/test/expected/bullet_full_featured.png +0 -0
  32. data/test/expected/bullet_tall.png +0 -0
  33. data/test/expected/bullet_wide.png +0 -0
  34. data/test/expected/discrete.png +0 -0
  35. data/test/expected/discrete_wide.png +0 -0
  36. data/test/expected/error.png +0 -0
  37. data/test/expected/labeled_area.png +0 -0
  38. data/test/expected/labeled_bar.png +0 -0
  39. data/test/expected/labeled_discrete.png +0 -0
  40. data/test/expected/labeled_pie.png +0 -0
  41. data/test/expected/labeled_smooth.png +0 -0
  42. data/test/expected/labeled_whisker_decimals.png +0 -0
  43. data/test/expected/pie.png +0 -0
  44. data/test/expected/pie0.png +0 -0
  45. data/test/expected/pie1.png +0 -0
  46. data/test/expected/pie100.png +0 -0
  47. data/test/expected/pie45.png +0 -0
  48. data/test/expected/pie95.png +0 -0
  49. data/test/expected/pie99.png +0 -0
  50. data/test/expected/pie_flat.png +0 -0
  51. data/test/expected/pie_large.png +0 -0
  52. data/test/expected/smooth.png +0 -0
  53. data/test/expected/smooth_colored.png +0 -0
  54. data/test/expected/smooth_similar_nonzero_values.png +0 -0
  55. data/test/expected/smooth_underneath_color.png +0 -0
  56. data/test/expected/smooth_with_target.png +0 -0
  57. data/test/expected/standard_deviation.png +0 -0
  58. data/test/expected/standard_deviation_short.png +0 -0
  59. data/test/expected/standard_deviation_tall.png +0 -0
  60. data/test/expected/whisker.png +0 -0
  61. data/test/expected/whisker_junk.png +0 -0
  62. data/test/expected/whisker_non_exceptional.png +0 -0
  63. data/test/expected/whisker_with_step.png +0 -0
  64. data/test/test_all.rb +302 -0
  65. metadata +126 -0
data/lib/sparklines.rb ADDED
@@ -0,0 +1,785 @@
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
+ bullet
59
+ whisker
60
+
61
+ General Defaults:
62
+
63
+ :type => 'smooth'
64
+ :height => 14px
65
+ :upper => 50
66
+ :above_color => 'red'
67
+ :below_color => 'grey'
68
+ :background_color => 'white'
69
+ :line_color => 'lightgrey'
70
+
71
+ ==License
72
+
73
+ Licensed under the MIT license.
74
+
75
+ =end
76
+ class Sparklines
77
+
78
+ VERSION = '0.5.1'
79
+
80
+ @@label_margin = 5.0
81
+ @@pointsize = 10.0
82
+
83
+ class << self
84
+
85
+ ##
86
+ # Plots a sparkline and returns a Magic::Image object.
87
+
88
+ def plot_to_image(data=[], options={})
89
+ defaults = {
90
+ :type => 'smooth',
91
+ :height => 14,
92
+ :upper => 50,
93
+ :diameter => 20,
94
+ :step => 2,
95
+ :line_color => 'lightgrey',
96
+
97
+ :above_color => 'red',
98
+ :below_color => 'grey',
99
+ :background_color => 'white',
100
+ :share_color => 'red',
101
+ :remain_color => 'lightgrey',
102
+ :min_color => 'blue',
103
+ :max_color => 'green',
104
+ :last_color => 'red',
105
+ :std_dev_color => '#efefef',
106
+
107
+ :has_min => false,
108
+ :has_max => false,
109
+ :has_last => false,
110
+ :has_std_dev => false,
111
+
112
+ :label => nil
113
+ }
114
+
115
+ # HACK for HashWithIndifferentAccess
116
+ options_sym = Hash.new
117
+ options.keys.each do |key|
118
+ options_sym[key.to_sym] = options[key]
119
+ end
120
+
121
+ options_sym = defaults.merge(options_sym)
122
+
123
+ # Call the appropriate method for actual plotting.
124
+ sparkline = self.new(data, options_sym)
125
+ if %w(area bar bullet pie smooth discrete whisker).include? options_sym[:type]
126
+ sparkline.send options_sym[:type]
127
+ else
128
+ sparkline.plot_error options_sym
129
+ end
130
+ end
131
+
132
+ ##
133
+ # Does the actual plotting of the graph.
134
+ # Calls the appropriate subclass based on the :type argument.
135
+ # Defaults to 'smooth'.
136
+ #
137
+ # Returns a blob.
138
+
139
+ def plot(data=[], options={})
140
+ plot_to_image(data, options).to_blob
141
+ end
142
+
143
+ ##
144
+ # Writes a graph to disk with the specified filename, or "sparklines.png".
145
+
146
+ def plot_to_file(filename="sparklines.png", data=[], options={})
147
+ File.open( filename, 'wb' ) do |png|
148
+ png << self.plot( data, options)
149
+ end
150
+ end
151
+
152
+ end # class methods
153
+
154
+ def initialize(data=[], options={})
155
+ @data = Array(data)
156
+ @options = options
157
+ normalize_data
158
+ end
159
+
160
+ ##
161
+ # Creates a continuous area sparkline. Relevant options.
162
+ #
163
+ # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
164
+ #
165
+ # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
166
+ #
167
+ # :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.
168
+ #
169
+ # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false.
170
+ #
171
+ # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false.
172
+ #
173
+ # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false.
174
+ #
175
+ # :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.
176
+ #
177
+ # :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.
178
+ #
179
+ # :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.
180
+ #
181
+ # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
182
+ #
183
+ # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
184
+
185
+ def area
186
+
187
+ step = @options[:step].to_f
188
+ height = @options[:height].to_f
189
+ background_color = @options[:background_color]
190
+
191
+ create_canvas((@norm_data.size - 1) * step + 4, height, background_color)
192
+
193
+ upper = @options[:upper].to_f
194
+
195
+ has_min = @options[:has_min]
196
+ has_max = @options[:has_max]
197
+ has_last = @options[:has_last]
198
+
199
+ min_color = @options[:min_color]
200
+ max_color = @options[:max_color]
201
+ last_color = @options[:last_color]
202
+ below_color = @options[:below_color]
203
+ above_color = @options[:above_color]
204
+
205
+
206
+ coords = [[0,(height - 3 - upper/(101.0/(height-4)))]]
207
+ i=0
208
+ @norm_data.each do |r|
209
+ coords.push [(2 + i), (height - 3 - r/(101.0/(height-4)))]
210
+ i += step
211
+ end
212
+ coords.push [(@norm_data.size - 1) * step + 4, (height - 3 - upper/(101.0/(height-4)))]
213
+
214
+ # TODO Refactor! Should take a block and do both.
215
+ #
216
+ # Block off the bottom half of the image and draw the sparkline
217
+ @draw.fill(above_color)
218
+ @draw.define_clip_path('top') do
219
+ @draw.rectangle(0,0,(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
220
+ end
221
+ @draw.clip_path('top')
222
+ @draw.polygon(*coords.flatten)
223
+
224
+ # Block off the top half of the image and draw the sparkline
225
+ @draw.fill(below_color)
226
+ @draw.define_clip_path('bottom') do
227
+ @draw.rectangle(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,height)
228
+ end
229
+ @draw.clip_path('bottom')
230
+ @draw.polygon(*coords.flatten)
231
+
232
+ # The sparkline looks kinda nasty if either the above_color or below_color gets the center line
233
+ @draw.fill('black')
234
+ @draw.line(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
235
+
236
+ # After the parts have been masked, we need to let the whole canvas be drawable again
237
+ # so a max dot can be displayed
238
+ @draw.define_clip_path('all') do
239
+ @draw.rectangle(0,0,@canvas.columns,@canvas.rows)
240
+ end
241
+ @draw.clip_path('all')
242
+
243
+ drawbox(coords[@norm_data.index(@norm_data.min)+1], 1, min_color) if has_min == true
244
+ drawbox(coords[@norm_data.index(@norm_data.max)+1], 1, max_color) if has_max == true
245
+
246
+ drawbox(coords[-2], 1, last_color) if has_last == true
247
+
248
+ @draw.draw(@canvas)
249
+ @canvas
250
+ end
251
+
252
+ ##
253
+ # A bar graph.
254
+ #
255
+ # Also takes :target option (a line will be drawn) and :upper (values
256
+ # under will be drawn in :below_color, above in :above_color).
257
+
258
+ def bar
259
+ step = @options[:step].to_f
260
+ height = @options[:height].to_f
261
+ width = ((@norm_data.size - 1) * step).to_f
262
+ background_color = @options[:background_color]
263
+
264
+ create_canvas(@norm_data.length * step + 2, height, background_color)
265
+
266
+ upper = @options[:upper].to_f
267
+ below_color = @options[:below_color]
268
+ above_color = @options[:above_color]
269
+
270
+ target = @options.has_key?(:target) ? @options[:target].to_f : nil
271
+ target_color = @options[:target_color] || 'white'
272
+
273
+ i = 1
274
+ # raise @norm_data.to_yaml
275
+ max_normalized = @norm_data.max
276
+ @norm_data.each_with_index do |r, index|
277
+ color = (@data[index] >= upper) ? above_color : below_color
278
+ @draw.stroke('transparent')
279
+ @draw.fill(color)
280
+ bar_height_from_top = @canvas.rows - ( (r.to_f / max_normalized.to_f) * @canvas.rows)
281
+ @draw.rectangle( i, @canvas.rows, i + step - 2, bar_height_from_top )
282
+ i += step
283
+ end
284
+
285
+ unless target.nil?
286
+ normalized_target_value = ((target.to_f - @minimum_value)/(@maximum_value - @minimum_value)) * 100.0
287
+ adjusted_target_value = (height - 3 - normalized_target_value/(101.0/(height-4))).to_i
288
+ @draw.stroke(target_color)
289
+ open_ended_polyline([[-5, adjusted_target_value], [width + 5, adjusted_target_value]])
290
+ end
291
+
292
+ @draw.draw(@canvas)
293
+ @canvas
294
+ end
295
+
296
+
297
+ ##
298
+ # Creates a discretized sparkline
299
+ #
300
+ # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
301
+ #
302
+ # :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.
303
+ #
304
+ # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
305
+ #
306
+ # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
307
+
308
+ def discrete
309
+
310
+ height = @options[:height].to_f
311
+ upper = @options[:upper].to_f
312
+ background_color = @options[:background_color]
313
+ step = @options[:step].to_f
314
+
315
+ width = @norm_data.size * step - 1
316
+
317
+ create_canvas(@norm_data.size * step - 1, height, background_color)
318
+
319
+ below_color = @options[:below_color]
320
+ above_color = @options[:above_color]
321
+ std_dev_color = @options[:std_dev_color]
322
+
323
+ drawstddevbox(width,height,std_dev_color) if @options[:has_std_dev] == true
324
+
325
+ i = 0
326
+ @norm_data.each do |r|
327
+ color = (r >= upper) ? above_color : below_color
328
+ @draw.stroke(color)
329
+ @draw.line(i, (@canvas.rows - r/(101.0/(height-4))-4).to_f,
330
+ i, (@canvas.rows - r/(101.0/(height-4))).to_f)
331
+ i += step
332
+ end
333
+
334
+ @draw.draw(@canvas)
335
+ @canvas
336
+ end
337
+
338
+
339
+ ##
340
+ # Creates a pie-chart sparkline
341
+ #
342
+ # :diameter - An integer that determines what the size of the sparkline will be. Defaults to 20
343
+ #
344
+ # :share_color - A string or color code representing the color to draw the share of the pie represented by percent. Defaults to red.
345
+ #
346
+ # :remain_color - A string or color code representing the color to draw the pie not taken by the share color. Defaults to lightgrey.
347
+
348
+ def pie
349
+ diameter = @options[:diameter].to_f
350
+ background_color = @options[:background_color]
351
+
352
+ create_canvas(diameter, diameter, background_color)
353
+
354
+ share_color = @options[:share_color]
355
+ remain_color = @options[:remain_color]
356
+ percent = @norm_data[0]
357
+
358
+ # Adjust the radius so there's some edge left in the pie
359
+ r = diameter/2.0 - 2
360
+ @draw.fill(remain_color)
361
+ @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
362
+ @draw.fill(share_color)
363
+
364
+ # Special exceptions
365
+ if percent == 0
366
+ # For 0% return blank
367
+ @draw.draw(@canvas)
368
+ return @canvas
369
+ elsif percent == 100
370
+ # For 100% just draw a full circle
371
+ @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
372
+ @draw.draw(@canvas)
373
+ return @canvas
374
+ end
375
+
376
+ # Okay, this part is as confusing as hell, so pay attention:
377
+ # This line determines the horizontal portion of the point on the circle where the X-Axis
378
+ # should end. It's caculated by taking the center of the on-image circle and adding that
379
+ # to the radius multiplied by the formula for determinig the point on a unit circle that a
380
+ # angle corresponds to. 3.6 * percent gives us that angle, but it's in degrees, so we need to
381
+ # convert, hence the muliplication by Pi over 180
382
+ arc_end_x = r + 2 + (r * Math.cos((3.6 * percent)*(Math::PI/180)))
383
+
384
+ # The same goes for here, except it's the vertical point instead of the horizontal one
385
+ arc_end_y = r + 2 + (r * Math.sin((3.6 * percent)*(Math::PI/180)))
386
+
387
+ # Because the SVG path format is seriously screwy, we need to set the large-arc-flag to 1
388
+ # if the angle of an arc is greater than 180 degrees. I have no idea why this is, but it is.
389
+ percent > 50? large_arc_flag = 1: large_arc_flag = 0
390
+
391
+ # This is also confusing
392
+ # M tells us to move to an absolute point on the image. We're moving to the center of the pie
393
+ # h tells us to move to a relative point. We're moving to the right edge of the circle.
394
+ # A tells us to start an absolute elliptical arc. The first two values are the radii of the ellipse
395
+ # the third value is the x-axis-rotation (how to rotate the ellipse if we wanted to [could have some fun
396
+ # with randomizing that maybe), the fourth value is our large-arc-flag, the fifth is the sweep-flag,
397
+ # (again, confusing), the sixth and seventh values are the end point of the arc which we calculated previously
398
+ # More info on the SVG path string format at: http://www.w3.org/TR/SVG/paths.html
399
+ path = "M#{r + 2},#{r + 2} h#{r} A#{r},#{r} 0 #{large_arc_flag},1 #{arc_end_x},#{arc_end_y} z"
400
+ @draw.path(path)
401
+
402
+ @draw.draw(@canvas)
403
+ @canvas
404
+ end
405
+
406
+ ##
407
+ # Creates a smooth line graph sparkline.
408
+ #
409
+ # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
410
+ #
411
+ # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
412
+ #
413
+ # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false.
414
+ #
415
+ # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false.
416
+ #
417
+ # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false.
418
+ #
419
+ # :has_std_dev - Determines whether there will be a standard deviation bar behind the smooth graph or not. Defaults to false.
420
+ #
421
+ # :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.
422
+ #
423
+ # :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.
424
+ #
425
+ # :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.
426
+ #
427
+ # :std_dev_color - A string or color code representing the color that the standard deviation bar behind the smooth graph will be displayed as. Defaults to #efefef
428
+ #
429
+ # :underneath_color - A string or color code representing the color that will be used to fill in the area underneath the line. Optional.
430
+ #
431
+ # :target - A 1px horizontal line will be drawn at this value. Useful for showing an average.
432
+ #
433
+ # :target_color - Color of the target line. Defaults to white.
434
+
435
+ def smooth
436
+ step = @options[:step].to_f
437
+ height = @options[:height].to_f
438
+ width = ((@norm_data.size - 1) * step).to_f
439
+
440
+ background_color = @options[:background_color]
441
+ create_canvas(width, height, background_color)
442
+
443
+ min_color = @options[:min_color]
444
+ max_color = @options[:max_color]
445
+ last_color = @options[:last_color]
446
+ has_min = @options[:has_min]
447
+ has_max = @options[:has_max]
448
+ has_last = @options[:has_last]
449
+ line_color = @options[:line_color]
450
+ has_std_dev = @options[:has_std_dev]
451
+ std_dev_color = @options[:std_dev_color]
452
+
453
+ target = @options.has_key?(:target) ? @options[:target].to_f : nil
454
+ target_color = @options[:target_color] || 'white'
455
+
456
+ drawstddevbox(width,height,std_dev_color) if has_std_dev == true
457
+
458
+ @draw.stroke(line_color)
459
+ coords = []
460
+ i=0
461
+ @norm_data.each do |r|
462
+ coords.push [ i, (height - 3 - r/(101.0/(height-4))) ]
463
+ i += step
464
+ end
465
+
466
+ if @options[:underneath_color]
467
+ closed_polygon(height, width, coords)
468
+ else
469
+ open_ended_polyline(coords)
470
+ end
471
+
472
+ unless target.nil?
473
+ normalized_target_value = ((target.to_f - @minimum_value)/(@maximum_value - @minimum_value)) * 100.0
474
+ adjusted_target_value = (height - 3 - normalized_target_value/(101.0/(height-4))).to_i
475
+ @draw.stroke(target_color)
476
+ open_ended_polyline([[-5, adjusted_target_value], [width + 5, adjusted_target_value]])
477
+ end
478
+
479
+ drawbox(coords[@norm_data.index(@norm_data.min)], 2, min_color) if has_min == true
480
+ drawbox(coords[@norm_data.index(@norm_data.max)], 2, max_color) if has_max == true
481
+ drawbox(coords[-1], 2, last_color) if has_last == true
482
+
483
+ @draw.draw(@canvas)
484
+ @canvas
485
+ end
486
+
487
+ ##
488
+ # Creates a whisker sparkline to track on/off type data. There are five states:
489
+ # on, off, no value, exceptional on, exceptional off. On values create an up
490
+ # whisker and off values create a down whisker. Exceptional values may be
491
+ # colored differently than regular values to indicate, for example, a shut out.
492
+ # No value produces an empty row to indicate a tie.
493
+ #
494
+ # * results - an array of integer values between -2 and 2. -2 is exceptional
495
+ # down, -1 is regular down, 0 is no value, 1 is up, and 2 is exceptional up.
496
+ # * options - a hash that takes parameters
497
+ #
498
+ # :height - height of the sparkline
499
+ #
500
+ # :whisker_color - the color of regular whiskers; defaults to black
501
+ #
502
+ # :exception_color - the color of exceptional whiskers; defaults to red
503
+ #
504
+ # :step - Spacing for whiskers. Includes the whisker itself. Default 2.
505
+
506
+ def whisker
507
+
508
+ step = @options[:step].to_i
509
+ height = @options[:height].to_f
510
+ background_color = @options[:background_color]
511
+
512
+ create_canvas(@data.size * step - 1, height, background_color)
513
+
514
+ whisker_color = @options[:whisker_color] || 'black'
515
+ exception_color = @options[:exception_color] || 'red'
516
+
517
+ on_row = (@canvas.rows/2.0 - 1).ceil
518
+ off_row = (@canvas.rows/2.0).floor
519
+ i = 0
520
+ @data.each do |r|
521
+ color = whisker_color
522
+
523
+ if ( (r == 2 || r == -2) && exception_color )
524
+ color = exception_color
525
+ end
526
+
527
+ y_mid_point = (r >= 1) ? on_row : off_row
528
+
529
+ y_end_point = y_mid_point
530
+ if ( r > 0)
531
+ y_end_point = 0
532
+ end
533
+
534
+ if ( r < 0 )
535
+ y_end_point = @canvas.rows
536
+ end
537
+
538
+ @draw.stroke( color )
539
+ @draw.line( i, y_mid_point, i, y_end_point )
540
+ i += step
541
+ end
542
+
543
+ @draw.draw(@canvas)
544
+ @canvas
545
+ end
546
+
547
+ ##
548
+ # A bullet graph, a la Stephen Few in "Information Dashboard Design."
549
+ #
550
+ # * data - A single value for the thermometer part of the bullet.
551
+ # Represents the current value.
552
+ # * options - a hash
553
+ #
554
+ # :good - Numeric. Maximum value that will be shown on the graph. Required.
555
+ #
556
+ # :height - Numeric. Defaults to 15. Should be a multiple of three.
557
+ #
558
+ # :width - This graph expands to any specified width. Defaults to 100.
559
+ #
560
+ # :bad - Numeric. A darker shade background will be drawn up to this point.
561
+ #
562
+ # :satisfactory - Numeric. A medium background will be drawn up to this point.
563
+ #
564
+ # :target - Numeric value. A thin vertical bar will be drawn.
565
+ #
566
+ # :good_color - Color for the rightmost section of the bullet.
567
+ #
568
+ # :satisfactory_color - Color for the middle background of the bullet.
569
+ #
570
+ # :bad_color - Color for the lowest, leftmost section.
571
+
572
+ def bullet
573
+ height = @options[:height].to_f
574
+ @graph_width = @options.has_key?(:width) ? @options[:width].to_f : 100.0
575
+ good_color = @options.has_key?(:good_color) ? @options[:good_color] : '#eeeeee'
576
+ satisfactory_color = @options.has_key?(:satisfactory_color) ? @options[:satisfactory_color] : '#bbbbbb'
577
+ bad_color = @options.has_key?(:bad_color) ? @options[:bad_color] : '#999999'
578
+ bullet_color = @options.has_key?(:bullet_color) ? @options[:bullet_color] : 'black'
579
+ @thickness = height/3.0
580
+
581
+ create_canvas(@graph_width, height, good_color)
582
+
583
+ @value = @norm_data
584
+ @good_value = @options[:good].to_f
585
+
586
+ @graph_height = @options[:height]
587
+
588
+ qualitative_range_colors = [satisfactory_color, bad_color]
589
+ [:satisfactory, :bad].each_with_index do |indicator, index|
590
+ next unless @options.has_key?(indicator)
591
+ @draw = @draw.fill(qualitative_range_colors[index])
592
+ indicator_width_x = @graph_width * (@options[indicator].to_f / @good_value)
593
+ @draw = @draw.rectangle(0, 0, indicator_width_x.to_i, @graph_height)
594
+ end
595
+
596
+ if @options.has_key?(:target)
597
+ @draw = @draw.fill(bullet_color)
598
+ target_x = @graph_width * (@options[:target].to_f / @good_value)
599
+ half_thickness = (@thickness / 2.0).to_i
600
+ bar_width = 1.0
601
+ @draw = @draw.rectangle(target_x.to_i, half_thickness, (target_x + bar_width).to_i, @thickness * 2 + half_thickness)
602
+ end
603
+
604
+ # Value
605
+ @draw = @draw.fill(bullet_color)
606
+ @draw = @draw.rectangle(0, @thickness.to_i, @graph_width * (@data.first.to_f / @good_value), (@thickness * 2.0).to_i)
607
+
608
+ @draw.draw(@canvas)
609
+ @canvas
610
+ end
611
+
612
+ ##
613
+ # Draw the error Sparkline.
614
+
615
+ def plot_error(options={})
616
+ create_canvas(40, 15, 'white')
617
+
618
+ @draw.fill('red')
619
+ @draw.line(0,0,40,15)
620
+ @draw.line(0,15,40,0)
621
+
622
+ @draw.draw(@canvas)
623
+ @canvas
624
+ end
625
+
626
+ private
627
+
628
+ def normalize_data
629
+ case @options[:type].to_s
630
+ when 'bar'
631
+ @minimum_value = 0.0
632
+ else
633
+ @minimum_value = @data.min
634
+ end
635
+
636
+ @maximum_value = @data.max
637
+
638
+ case @options[:type].to_s
639
+ when 'pie'
640
+ @norm_data = @data
641
+ when 'bullet'
642
+ @norm_data = @data
643
+ else
644
+ @norm_data = @data.map do |value|
645
+ value = ((value.to_f - @minimum_value)/(@maximum_value - @minimum_value)) * 100.0
646
+ end
647
+ end
648
+ end
649
+
650
+ ##
651
+ # :arr - an array of points (represented as two element arrays)
652
+
653
+ def open_ended_polyline(arr)
654
+ 0.upto(arr.length - 2) { |i|
655
+ @draw.line(arr[i][0], arr[i][1], arr[i+1][0], arr[i+1][1])
656
+ }
657
+ end
658
+
659
+ # Fills in the area under the line (used for a smooth graph)
660
+ def closed_polygon(height, width, coords)
661
+ return if @options[:underneath_color].nil?
662
+ list = []
663
+ # Start off screen so completed polygon doesn't show
664
+ list << [-1, height + 1]
665
+ list << [coords.first.first - 1, coords.first.last]
666
+ # Now the normal coords
667
+ list << coords
668
+ # Close offscreen
669
+ list << [coords.last.first + 1, coords.last.last]
670
+ list << [width + 1, height + 1]
671
+ @draw.fill( @options[:underneath_color] )
672
+ @draw.polygon( *list.flatten )
673
+ end
674
+
675
+ ##
676
+ # Create an image to draw on and a drawable to do the drawing with.
677
+ #
678
+ # TODO Refactor into smaller methods
679
+
680
+ def create_canvas(w, h, bkg_col)
681
+ @draw = Magick::Draw.new
682
+ @draw.pointsize = @@pointsize # TODO Use height
683
+ @draw.pointsize = @options[:font_size] if @options.has_key?(:font_size)
684
+ @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
685
+
686
+ # Make room for label and last value
687
+ unless @options[:label].nil?
688
+ @options[:has_last] = true
689
+ @label_width = calculate_width(@options[:label])
690
+ @data_last_width = calculate_width(@data.last)
691
+ # HACK The 7.0 is a severe hack. Must figure out correct spacing
692
+ @label_and_data_last_width = @label_width + @data_last_width + @@label_margin * 7.0
693
+ w += @label_and_data_last_width
694
+ end
695
+
696
+ @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
697
+ @canvas.format = "PNG"
698
+
699
+ # Draw label and last value
700
+ unless @options[:label].nil?
701
+ if ENV.has_key?('MAGICK_FONT_PATH')
702
+ vera_font_path = File.expand_path('Vera.ttf', ENV['MAGICK_FONT_PATH'])
703
+ @font = File.exists?(vera_font_path) ? vera_font_path : nil
704
+ else
705
+ @font = nil
706
+ end
707
+ @font = @options[:font] if @options.has_key?(:font)
708
+
709
+ @draw.fill = 'black'
710
+ @draw.font = @font if @font
711
+ @draw.gravity = Magick::WestGravity
712
+ @draw.annotate( @canvas,
713
+ @label_width, 1.0,
714
+ w - @label_and_data_last_width + @@label_margin, h - calculate_caps_height/2.0,
715
+ @options[:label])
716
+
717
+ @draw.fill = 'red'
718
+ @draw.annotate( @canvas,
719
+ @data_last_width, 1.0,
720
+ w - @data_last_width - @@label_margin * 2.0, h - calculate_caps_height/2.0,
721
+ @data.last.to_s)
722
+ end
723
+ end
724
+
725
+ ##
726
+ # Utility to draw a coloured box
727
+ # Centred on pt, offset off in each direction, fill color is col
728
+
729
+ def drawbox(pt, offset, color)
730
+ @draw.stroke 'transparent'
731
+ @draw.fill(color)
732
+ @draw.rectangle(pt[0]-offset, pt[1]-offset, pt[0]+offset, pt[1]+offset)
733
+ end
734
+
735
+ ##
736
+ # Utility to draw the standard deviation box
737
+ #
738
+ def drawstddevbox(width,height,color)
739
+ mid=@norm_data.inject(0) {|sum,v| sum+=v}/@norm_data.size
740
+ std_dev = standard_deviation(@norm_data)
741
+ lower = (height - 3 - (mid-std_dev)/(101.0/(height-4)))
742
+ upper = (height - 3 - (mid+std_dev)/(101.0/(height-4)))
743
+ @draw.stroke 'transparent'
744
+ @draw.fill(color)
745
+ @draw.rectangle(0, lower, width, upper)
746
+ end
747
+
748
+ def calculate_width(text)
749
+ @draw.get_type_metrics(@canvas, text.to_s).width
750
+ end
751
+
752
+ def calculate_caps_height
753
+ @draw.get_type_metrics(@canvas, 'X').height
754
+ end
755
+
756
+ ##
757
+ # Calculation helper for standard deviation.
758
+ #
759
+ # Thanks to Warren Seen
760
+ # http://warrenseen.com/blog/2006/03/13/how-to-calculate-standard-deviation/
761
+ def variance(population)
762
+ n = 0
763
+ mean = 0.0
764
+ s = 0.0
765
+ population.each { |x|
766
+ n = n + 1
767
+ delta = x - mean
768
+ mean = mean + (delta / n)
769
+ s = s + delta * (x - mean)
770
+ }
771
+ # if you want to calculate std deviation
772
+ # of a sample change this to "s / (n-1)"
773
+ return s / n
774
+ end
775
+
776
+ ##
777
+ # Calculate the standard deviation of a population
778
+ #
779
+ # accepts: an array, the population
780
+ # returns: the standard deviation
781
+ def standard_deviation(population)
782
+ Math.sqrt(variance(population))
783
+ end
784
+
785
+ end