sparklines 0.2.7 → 0.4.0

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 (6) hide show
  1. data/CHANGELOG +6 -0
  2. data/README +12 -26
  3. data/lib/sparklines.rb +448 -421
  4. data/rakefile +16 -0
  5. data/test/all_test.rb +89 -44
  6. metadata +2 -2
data/CHANGELOG CHANGED
@@ -1,4 +1,10 @@
1
1
 
2
+ 0.3.0
3
+
4
+ * Changed to a Class for maintainability
5
+ * All values are normalized (except pie)
6
+ * A single value can be passed for the pie percentage (instead of an Array)
7
+
2
8
  0.2.7
3
9
 
4
10
  * Fixed bug where last element of bar graph wouldn't go to the bottom [Esad Hajdarevic esad@esse.at]
data/README CHANGED
@@ -1,36 +1,22 @@
1
- **********************************
2
- ** Spark Graph Library for Ruby **
3
- **********************************
1
+ == Sparklines
4
2
 
5
- Geoffrey Grosenbach
6
- boss@topfunky.com
7
- http://nubyonrails.topfunky.com
8
-
9
- Daniel Nugent
10
- nugend@gmail.com
11
-
12
-
13
- *** What is it? ***
3
+ A library for generating small sparkline graphs from Ruby. Use it in desktop apps or with Ruby on Rails.
14
4
 
15
- A library for generating small sparkline graphs from Ruby. Use it in desktop apps or Rails apps. See the samples in the 'samples' directory.
5
+ == Other info
16
6
 
7
+ http://nubyonrails.com/pages/sparklines
17
8
 
18
- *** How do I use it? ***
9
+ == Rails plugin
19
10
 
20
- Read the meager documentation in the enclosed 'docs' folder.
11
+ http://topfunky.net/svn/plugins/sparklines
21
12
 
22
- In Rails, copy the included files (sparklines_controller.rb, sparklines_helper.rb, sparklines.rb) into your controller, helper, and lib directories, respectively.
13
+ == Authors
23
14
 
24
- In your custom controller, do
25
- require_dependency 'sparklines'
26
- and
27
- helper :sparklines
28
-
29
- In your view, call it like this:
30
-
31
- <%= sparklines_tag [1,2,3,4,5,6] %> <!-- Gives you a smooth graph -->
15
+ Geoffrey Grosenbach
16
+ boss@topfunky.com
17
+ http://nubyonrails.com/pages/sparklines
32
18
 
33
- Or specify details:
19
+ Daniel Nugent
20
+ nugend@gmail.com
34
21
 
35
- <%= sparklines_tag [1,2,3,4,5,6], :type => 'discrete', :height => 10, :upper => 80, :above_color => 'green', :below_color => 'blue' %>
36
22
 
data/lib/sparklines.rb CHANGED
@@ -1,12 +1,13 @@
1
1
 
2
+ require 'rubygems'
2
3
  require 'RMagick'
3
4
  require 'mathn'
4
5
 
5
6
  =begin rdoc
6
7
 
7
- A library (in Ruby!) for generating sparklines.
8
+ A library for generating small unmarked graphs (sparklines).
8
9
 
9
- Can be used to write to a file or make a web service with Rails or other Ruby CGI apps.
10
+ Can be used to write an image to a file or make a web service with Rails or other Ruby CGI apps.
10
11
 
11
12
  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
 
@@ -14,28 +15,10 @@ Requires the RMagick image library.
14
15
 
15
16
  ==Authors
16
17
 
17
- {Dan Nugent}[mailto:nugend@gmail.com]
18
- Original port from Python Sparklines library.
19
-
18
+ {Dan Nugent}[mailto:nugend@gmail.com] Original port from Python Sparklines library.
20
19
 
21
20
  {Geoffrey Grosenbach}[mailto:boss@topfunky.com] -- http://nubyonrails.topfunky.com
22
- -- Conversion to module and addition of functions for using with Rails. Also changed functions to use Rails-style option hashes for parameters.
23
-
24
- ===Tangent regarding RMagick
25
-
26
- The easiest way to use RMagick on Mac OS X is to use darwinports. There are packages for libpng, freetype, and all the other libraries you need.
27
-
28
- I had a heck of a time getting RMagick to work on my system so in the interests of saving other people the trouble here's a little set of instructions on how to get RMagick working properly and with the right image formats.
29
-
30
- 1. Install the zlib[http://www.libpng.org/pub/png/libpng.html] library
31
- 2. With zlib in the same directory as the libpng[http://www.libpng.org/pub/png/libpng.html] library, install libpng
32
- 3. Option step: Install the {jpeg library}[ftp://ftp.uu.net/graphics/jpeg/jpegsrc.v6b.tar.gz] (You need it to use jpegs and you might want to have it)
33
- 4. Install ImageMagick from *source*[http://www.imagemagick.org/script/install-source.php]. RMagick requires the ImageMagick headers, so this is important.
34
- 5. Install RMagick from source[http://rubyforge.org/projects/rmagick/]. The gem is not reliable.
35
- 6. Edit Magick-conf if necessary. I had to remove -lcms and -ltiff since I didn't want those to be built and the libraries weren't on my system.
36
-
37
- Please keep in mind that these were only the steps that made RMagick work on my machine. This is a tricky library to get working.
38
- Consider using Joe Gregorio's version for Python if the installation proves to be too cumbersome.
21
+ -- Conversion to module and further maintenance.
39
22
 
40
23
  ==General Usage and Defaults
41
24
 
@@ -43,29 +26,28 @@ To use in a script:
43
26
 
44
27
  require 'rubygems'
45
28
  require 'sparklines'
46
- Sparklines.plot([1,25,33,46,89,90,85,77,42], :type => 'discrete', :height => 20)
29
+ Sparklines.plot([1,25,33,46,89,90,85,77,42],
30
+ :type => 'discrete',
31
+ :height => 20)
47
32
 
48
33
  An image blob will be returned which you can print, write to STDOUT, etc.
49
34
 
50
- In Rails,
51
-
52
- * Install the 'sparklines_generator' gem ('gem install sparklines_generator')
53
- * Call 'ruby script/generate sparklines'. This will copy the Sparklines controller and helper to your rails directories
54
- * Add "require 'sparklines'" to the bottom of your config/environment.rb
55
- * Restart your fcgi's or your WEBrick if necessary
35
+ For use with Ruby on Rails, see the sparklines plugin:
56
36
 
57
- And finally, add this to the controller whose view will be using sparklines:
58
-
59
- helper :sparklines
37
+ http://nubyonrails.com/pages/sparklines
60
38
 
61
39
  In your view, call it like this:
62
40
 
63
- <%= sparkline_tag [1,2,3,4,5,6] %> <!-- Gives you a smooth graph -->
41
+ <%= sparkline_tag [1,2,3,4,5,6] %>
64
42
 
65
43
  Or specify details:
66
44
 
67
- <%= sparkline_tag [1,2,3,4,5,6], :type => 'discrete', :height => 10, :upper => 80, :above_color => 'green', :below_color => 'blue' %>
68
-
45
+ <%= sparkline_tag [1,2,3,4,5,6],
46
+ :type => 'discrete',
47
+ :height => 10,
48
+ :upper => 80,
49
+ :above_color => 'green',
50
+ :below_color => 'blue' %>
69
51
 
70
52
  Graph types:
71
53
 
@@ -73,413 +55,458 @@ Graph types:
73
55
  discrete
74
56
  pie
75
57
  smooth
76
- bar (results will be normalized, i.e. scaled to take up the full height of the graph)
58
+ bar
77
59
 
78
60
  General Defaults:
79
61
 
80
- :type => 'smooth'
81
- :height => 14px
82
- :upper => 50
83
- :above_color => 'red'
84
- :below_color => 'grey'
85
- :background_color => 'white'
86
- :line_color => 'lightgrey'
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'
87
69
 
88
70
  ==License
89
71
 
90
72
  Licensed under the MIT license.
91
73
 
92
74
  =end
75
+ class Sparklines
76
+
77
+ VERSION = '0.4.0'
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).include? options_sym[:type]
123
+ sparkline.send options_sym[:type]
124
+ else
125
+ sparkline.plot_error options_sym
126
+ end
127
+ end
93
128
 
94
- module Sparklines
95
- VERSION = '0.2.7'
96
-
97
- # Does the actually plotting of the graph. Calls the appropriate function based on the :type value passed. Defaults to 'smooth.'
98
- def Sparklines.plot(results=[], options={})
99
- defaults = { :type => 'smooth',
100
- :height => 14,
101
- :upper => 50,
102
- :diameter => 20,
103
- :step => 2,
104
- :line_color => 'lightgrey',
105
-
106
- :above_color => 'red',
107
- :below_color => 'grey',
108
- :background_color => 'white',
109
- :share_color => 'blue',
110
- :remain_color => 'lightgrey',
111
- :min_color => 'blue',
112
- :max_color => 'green',
113
- :last_color => 'red',
114
-
115
- :has_min => false,
116
- :has_max => false,
117
- :has_last => false
118
- }
119
-
120
- # Have to do this to get around HashWithIndifferentAccess
121
- options_sym = Hash.new
122
- options.keys.reverse.each do |key|
123
- options_sym[key.to_sym] = options[key]
124
- end
125
-
126
- options_sym = defaults.merge(options_sym)
127
-
128
- # Minimal normalization
129
- maximum_value = self.get_max_value(results).to_f
130
-
131
- # Call the appropriate function for actual plotting
132
- self.send(options_sym[:type], results, options_sym, maximum_value)
133
- end
134
-
135
- # Writes a graph to disk with the specified filename, or "Sparklines.png"
136
- def Sparklines.plot_to_file(filename="sparklines.png", results=[], options={})
137
- File.open( filename, 'wb' ) do |png|
138
- png << self.plot( results, options)
139
- end
140
- end
141
-
142
- # Creates a pie-chart sparkline
143
- #
144
- # * results - an array of integer values between 0 and 100 inclusive. Only the first integer will be accepted. It will be used to determine the percentage of the pie that is filled by the share_color
145
- #
146
- # * options - a hash that takes parameters:
147
- #
148
- # :diameter - An integer that determines what the size of the sparkline will be. Defaults to 20
149
- #
150
- # :share_color - A string or color code representing the color to draw the share of the pie represented by percent. Defaults to blue.
151
- #
152
- # :remain_color - A string or color code representing the color to draw the pie not taken by the share color. Defaults to lightgrey.
153
- def self.pie(results=[],options={}, maximum_value=100.0)
154
-
155
- diameter = options[:diameter].to_i
156
- share_color = options[:share_color]
157
- remain_color = options[:remain_color]
158
- percent = results[0]
159
-
160
- img = Magick::Image.new(diameter , diameter) {self.background_color = options[:background_color]}
161
- img.format = "PNG"
162
- draw = Magick::Draw.new
163
-
164
- #Adjust the radius so there's some edge left n the pie
165
- r = diameter/2.0 - 2
166
- draw.fill(remain_color)
167
- draw.ellipse(r + 2, r + 2, r , r , 0, 360)
168
- draw.fill(share_color)
169
-
170
- # Special exceptions
171
- if percent == 0
172
- # For 0% return blank
173
- draw.draw(img)
174
- return img.to_blob
175
- elsif percent == 100
176
- # For 100% just draw a full circle
177
- draw.ellipse(r + 2, r + 2, r , r , 0, 360)
178
- draw.draw(img)
179
- return img.to_blob
180
- end
181
-
182
- #Okay, this part is as confusing as hell, so pay attention:
183
- #This line determines the horizontal portion of the point on the circle where the X-Axis
184
- #should end. It's caculated by taking the center of the on-image circle and adding that
185
- #to the radius multiplied by the formula for determinig the point on a unit circle that a
186
- #angle corresponds to. 3.6 * percent gives us that angle, but it's in degrees, so we need to
187
- #convert, hence the muliplication by Pi over 180
188
- arc_end_x = r + 2 + (r * Math.cos((3.6 * percent)*(Math::PI/180)))
189
-
190
- #The same goes for here, except it's the vertical point instead of the horizontal one
191
- arc_end_y = r + 2 + (r * Math.sin((3.6 * percent)*(Math::PI/180)))
192
-
193
- #Because the SVG path format is seriously screwy, we need to set the large-arc-flag to 1
194
- #if the angle of an arc is greater than 180 degrees. I have no idea why this is, but it is.
195
- percent > 50? large_arc_flag = 1: large_arc_flag = 0
196
-
197
- #This is also confusing
198
- #M tells us to move to an absolute point on the image. We're moving to the center of the pie
199
- #h tells us to move to a relative point. We're moving to the right edge of the circle.
200
- #A tells us to start an absolute elliptical arc. The first two values are the radii of the ellipse
201
- #the third value is the x-axis-rotation (how to rotate the ellipse if we wanted to [could have some fun
202
- #with randomizing that maybe), the fourth value is our large-arc-flag, the fifth is the sweep-flag,
203
- #(again, confusing), the sixth and seventh values are the end point of the arc which we calculated previously
204
- #More info on the SVG path string format at: http://www.w3.org/TR/SVG/paths.html
205
- path = "M#{r + 2},#{r + 2} h#{r} A#{r},#{r} 0 #{large_arc_flag},1 #{arc_end_x},#{arc_end_y} z"
206
- draw.path(path)
207
-
208
- draw.draw(img)
209
- img.to_blob
210
- end
211
-
212
- # Creates a discretized sparkline
213
- #
214
- # * results is an array of integer values between 0 and 100 inclusive
215
- #
216
- # * options is a hash that takes 4 parameters:
217
- #
218
- # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
219
- #
220
- # :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.
221
- #
222
- # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
223
- #
224
- # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
225
- def self.discrete(results=[], options = {}, maximum_value=100.0)
226
-
227
- height = options[:height].to_i
228
- upper = options[:upper].to_i
229
- below_color = options[:below_color]
230
- above_color = options[:above_color]
231
-
232
- img = Magick::Image.new(results.size * 2 - 1, height) {self.background_color = options[:background_color]}
233
- img.format = "PNG"
234
- draw = Magick::Draw.new
235
-
236
- i = 0
237
- results.each do |r|
238
- color = (r >= upper) ? above_color : below_color
239
- draw.stroke(color)
240
- draw.line(i, (img.rows - r/(101.0/(height-4))-4).to_i,
241
- i, (img.rows - r/(101.0/(height-4))).to_i)
242
-
243
- i += 2
244
- end
245
-
246
- draw.draw(img)
247
- img.to_blob
248
- end
249
-
250
- # Creates a continuous area sparkline
251
- #
252
- # * results is an array of integer values between 0 and 100 inclusive
253
- #
254
- # * options is a hash that takes 4 parameters:
255
- #
256
- # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
257
- #
258
- # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
259
- #
260
- # :upper - An ineger 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.
261
- #
262
- # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaulst to false.
263
- #
264
- # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaulst to false.
265
- #
266
- # :has_last - Determines whether a dot will be drawn at the last value or not. Defaulst to false.
267
- #
268
- # :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.
269
- #
270
- # :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.
271
- #
272
- # :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.
273
- #
274
- # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
275
- #
276
- # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
277
- def self.area(results=[], options={}, maximum_value=100.0)
278
-
279
- step = options[:step].to_i
280
- height = options[:height].to_i
281
- upper = options[:upper].to_i
282
-
283
- has_min = options[:has_min]
284
- has_max = options[:has_max]
285
- has_last = options[:has_last]
286
-
287
- min_color = options[:min_color]
288
- max_color = options[:max_color]
289
- last_color = options[:last_color]
290
- below_color = options[:below_color]
291
- above_color = options[:above_color]
292
-
293
- img = Magick::Image.new((results.size - 1) * step + 4, height) {self.background_color = options[:background_color]}
294
- img.format = "PNG"
295
- draw = Magick::Draw.new
296
-
297
- coords = [[0,(height - 3 - upper/(101.0/(height-4)))]]
298
- i=0
299
- results.each do |r|
300
- coords.push [(2 + i), (height - 3 - r/(101.0/(height-4)))]
301
- i += step
302
- end
303
- coords.push [(results.size - 1) * step + 4, (height - 3 - upper/(101.0/(height-4)))]
304
-
305
- #Block off the bottom half of the image and draw the sparkline
306
- draw.fill(above_color)
307
- draw.define_clip_path('top') do
308
- draw.rectangle(0,0,(results.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
309
- end
310
- draw.clip_path('top')
311
- draw.polygon *coords.flatten
312
-
313
- #Block off the top half of the image and draw the sparkline
314
- draw.fill(below_color)
315
- draw.define_clip_path('bottom') do
316
- draw.rectangle(0,(height - 3 - upper/(101.0/(height-4))),(results.size - 1) * step + 4,height)
317
- end
318
- draw.clip_path('bottom')
319
- draw.polygon *coords.flatten
320
-
321
- #The sparkline looks kinda nasty if either the above_color or below_color gets the center line
322
- draw.fill('black')
323
- draw.line(0,(height - 3 - upper/(101.0/(height-4))),(results.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
324
-
325
- #After the parts have been masked, we need to let the whole canvas be drawable again
326
- #so a max dot can be displayed
327
- draw.define_clip_path('all') do
328
- draw.rectangle(0,0,img.columns,img.rows)
329
- end
330
- draw.clip_path('all')
331
- if has_min == 'true'
332
- min_pt = coords[results.index(results.min)+1]
333
- draw.fill(min_color)
334
- draw.rectangle(min_pt[0]-1, min_pt[1]-1, min_pt[0]+1, min_pt[1]+1)
335
- end
336
- if has_max == 'true'
337
- max_pt = coords[results.index(results.max)+1]
338
- draw.fill(max_color)
339
- draw.rectangle(max_pt[0]-1, max_pt[1]-1, max_pt[0]+1, max_pt[1]+1)
340
- end
341
- if has_last == 'true'
342
- last_pt = coords[-2]
343
- draw.fill(last_color)
344
- draw.rectangle(last_pt[0]-1, last_pt[1]-1, last_pt[0]+1, last_pt[1]+1)
345
- end
346
-
347
- draw.draw(img)
348
- img.to_blob
349
- end
350
-
351
- # Creates a smooth sparkline
352
- #
353
- # * results - an array of integer values between 0 and 100 inclusive
354
- #
355
- # * options - a hash that takes these optional parameters:
356
- #
357
- # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
358
- #
359
- # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
360
- #
361
- # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaulst to false.
362
- #
363
- # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaulst to false.
364
- #
365
- # :has_last - Determines whether a dot will be drawn at the last value or not. Defaulst to false.
366
- #
367
- # :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.
368
- #
369
- # :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.
370
- #
371
- # :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.
372
- def self.smooth(results, options, maximum_value=100.0)
373
-
374
- step = options[:step].to_i
375
- height = options[:height].to_i
376
- min_color = options[:min_color]
377
- max_color = options[:max_color]
378
- last_color = options[:last_color]
379
- has_min = options[:has_min]
380
- has_max = options[:has_max]
381
- has_last = options[:has_last]
382
- line_color = options[:line_color]
383
-
384
- img = Magick::Image.new((results.size - 1) * step + 4, height.to_i) {self.background_color = options[:background_color]}
385
- img.format = "PNG"
386
- draw = Magick::Draw.new
387
-
388
- draw.stroke(line_color)
389
- coords = []
390
- i=0
391
- results.each do |r|
392
- coords.push [ 2 + i, (height - 3 - r/(101.0/(height-4))) ]
393
- i += step
394
- end
395
-
396
- my_polyline(draw, coords)
397
-
398
- if has_min == true
399
- min_pt = coords[results.index(results.min)]
400
- draw.fill(min_color)
401
- draw.rectangle(min_pt[0]-2, min_pt[1]-2, min_pt[0]+2, min_pt[1]+2)
402
- end
403
- if has_max == true
404
- max_pt = coords[results.index(results.max)]
405
- draw.fill(max_color)
406
- draw.rectangle(max_pt[0]-2, max_pt[1]-2, max_pt[0]+2, max_pt[1]+2)
407
- end
408
- if has_last == true
409
- last_pt = coords[-1]
410
- draw.fill(last_color)
411
- draw.rectangle(last_pt[0]-2, last_pt[1]-2, last_pt[0]+2, last_pt[1]+2)
412
- end
413
-
414
- draw.draw(img)
415
- img.to_blob
416
- end
417
-
418
-
419
- # This is a function to replace the RMagick polyline function because it doesn't seem to work properly.
420
- #
421
- # * draw - a RMagick::Draw object.
422
- #
423
- # * arr - an array of points (represented as two element arrays)
424
- def self.my_polyline (draw, arr)
425
- i = 0
426
- while i < arr.size - 1
427
- draw.line(arr[i][0], arr[i][1], arr[i+1][0], arr[i+1][1])
428
- i += 1
429
- end
430
- end
431
-
432
- # Draw the error Sparkline. Not implemented yet.
433
- def self.plot_error(options={})
434
- img = Magick::Image.new(40,15) {self.background_color = options[:background_color]}
435
- img.format = "PNG"
436
- draw = Magick::Draw.new
437
- draw.fill('red')
438
- draw.line(0,0,40,15)
439
- draw.line(0,15,40,0)
440
- draw.draw(img)
441
-
442
- img.to_blob
443
- end
444
-
445
-
446
- # Draws a bar graph, normalized.
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
137
+
138
+ def initialize(data=[], options={})
139
+ @data = Array(data)
140
+ @options = options
141
+ normalize_data
142
+ end
143
+
144
+ # Creates a continuous area sparkline. Relevant options.
145
+ #
146
+ # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
147
+ #
148
+ # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
149
+ #
150
+ # :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.
151
+ #
152
+ # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false.
153
+ #
154
+ # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false.
155
+ #
156
+ # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false.
157
+ #
158
+ # :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.
159
+ #
160
+ # :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.
447
161
  #
448
- # BUG: Last column never goes all the way to the bottom.
449
- def self.bar(results=[], options = {}, maximum_value=100.0)
450
- step = options[:step].to_i
451
- height = options[:height].to_f
452
- upper = options[:upper].to_i
453
- below_color = options[:below_color]
454
- above_color = options[:above_color]
455
-
456
- img = Magick::Image.new((results.size) * step + 2, height) {
457
- self.background_color = options[:background_color]
458
- }
459
- img.format = "PNG"
460
- draw = Magick::Draw.new
162
+ # :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.
163
+ #
164
+ # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
165
+ #
166
+ # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
167
+ def area
168
+
169
+ step = @options[:step].to_i
170
+ height = @options[:height].to_i
171
+ background_color = @options[:background_color]
172
+
173
+ create_canvas((@norm_data.size - 1) * step + 4, height, background_color)
174
+
175
+ upper = @options[:upper].to_i
176
+
177
+ has_min = @options[:has_min]
178
+ has_max = @options[:has_max]
179
+ has_last = @options[:has_last]
180
+
181
+ min_color = @options[:min_color]
182
+ max_color = @options[:max_color]
183
+ last_color = @options[:last_color]
184
+ below_color = @options[:below_color]
185
+ above_color = @options[:above_color]
186
+
187
+
188
+ coords = [[0,(height - 3 - upper/(101.0/(height-4)))]]
189
+ i=0
190
+ @norm_data.each do |r|
191
+ coords.push [(2 + i), (height - 3 - r/(101.0/(height-4)))]
192
+ i += step
193
+ end
194
+ coords.push [(@norm_data.size - 1) * step + 4, (height - 3 - upper/(101.0/(height-4)))]
195
+
196
+ # TODO Refactor! Should take a block and do both.
197
+ #
198
+ # Block off the bottom half of the image and draw the sparkline
199
+ @draw.fill(above_color)
200
+ @draw.define_clip_path('top') do
201
+ @draw.rectangle(0,0,(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
202
+ end
203
+ @draw.clip_path('top')
204
+ @draw.polygon *coords.flatten
205
+
206
+ # Block off the top half of the image and draw the sparkline
207
+ @draw.fill(below_color)
208
+ @draw.define_clip_path('bottom') do
209
+ @draw.rectangle(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,height)
210
+ end
211
+ @draw.clip_path('bottom')
212
+ @draw.polygon *coords.flatten
213
+
214
+ # The sparkline looks kinda nasty if either the above_color or below_color gets the center line
215
+ @draw.fill('black')
216
+ @draw.line(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
217
+
218
+ # After the parts have been masked, we need to let the whole canvas be drawable again
219
+ # so a max dot can be displayed
220
+ @draw.define_clip_path('all') do
221
+ @draw.rectangle(0,0,@canvas.columns,@canvas.rows)
222
+ end
223
+ @draw.clip_path('all')
224
+
225
+ drawbox(coords[@norm_data.index(@norm_data.min)+1], 1, min_color) if has_min == true
226
+ drawbox(coords[@norm_data.index(@norm_data.max)+1], 1, max_color) if has_max == true
227
+
228
+ drawbox(coords[-2], 1, last_color) if has_last == true
229
+
230
+ @draw.draw(@canvas)
231
+ @canvas.to_blob
232
+ end
233
+
234
+
235
+ # Draws a bar graph.
236
+ #
237
+ def bar
238
+ step = @options[:step].to_i
239
+ height = @options[:height].to_f
240
+ background_color = @options[:background_color]
241
+
242
+ create_canvas(@norm_data.length * step + 2, height, background_color)
243
+
244
+ upper = @options[:upper].to_i
245
+ below_color = @options[:below_color]
246
+ above_color = @options[:above_color]
461
247
 
462
248
  i = 1
463
- results.each_with_index do |r, index|
249
+ @norm_data.each_with_index do |r, index|
464
250
  color = (r >= upper) ? above_color : below_color
465
- draw.stroke('transparent')
466
- draw.fill(color)
467
- draw.rectangle( i, img.rows,
468
- i + step - 2, img.rows - ((r / maximum_value) * img.rows) )
251
+ @draw.stroke('transparent')
252
+ @draw.fill(color)
253
+ @draw.rectangle( i, @canvas.rows,
254
+ i + step - 2, @canvas.rows - ( (r / @maximum_value) * @canvas.rows) )
255
+ i += step
256
+ end
257
+
258
+ @draw.draw(@canvas)
259
+ @canvas.to_blob
260
+ end
261
+
262
+
263
+ # Creates a discretized sparkline
264
+ #
265
+ # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
266
+ #
267
+ # :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.
268
+ #
269
+ # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
270
+ #
271
+ # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
272
+ def discrete
273
+
274
+ height = @options[:height].to_i
275
+ upper = @options[:upper].to_i
276
+ background_color = @options[:background_color]
277
+ step = @options[:step].to_i
278
+
279
+ create_canvas(@norm_data.size * step - 1, height, background_color)
280
+
281
+ below_color = @options[:below_color]
282
+ above_color = @options[:above_color]
283
+
284
+ i = 0
285
+ @norm_data.each do |r|
286
+ color = (r >= upper) ? above_color : below_color
287
+ @draw.stroke(color)
288
+ @draw.line(i, (@canvas.rows - r/(101.0/(height-4))-4).to_i,
289
+ i, (@canvas.rows - r/(101.0/(height-4))).to_i)
290
+ i += step
291
+ end
292
+
293
+ @draw.draw(@canvas)
294
+ @canvas.to_blob
295
+ end
296
+
297
+
298
+ # Creates a pie-chart sparkline
299
+ #
300
+ # :diameter - An integer that determines what the size of the sparkline will be. Defaults to 20
301
+ #
302
+ # :share_color - A string or color code representing the color to draw the share of the pie represented by percent. Defaults to red.
303
+ #
304
+ # :remain_color - A string or color code representing the color to draw the pie not taken by the share color. Defaults to lightgrey.
305
+ def pie
306
+
307
+ diameter = @options[:diameter].to_i
308
+ background_color = @options[:background_color]
309
+
310
+ create_canvas(diameter, diameter, background_color)
311
+
312
+ share_color = @options[:share_color]
313
+ remain_color = @options[:remain_color]
314
+ percent = @norm_data[0]
315
+
316
+ # Adjust the radius so there's some edge left in the pie
317
+ r = diameter/2.0 - 2
318
+ @draw.fill(remain_color)
319
+ @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
320
+ @draw.fill(share_color)
321
+
322
+ # Special exceptions
323
+ if percent == 0
324
+ # For 0% return blank
325
+ @draw.draw(@canvas)
326
+ return @canvas.to_blob
327
+ elsif percent == 100
328
+ # For 100% just draw a full circle
329
+ @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
330
+ @draw.draw(@canvas)
331
+ return @canvas.to_blob
332
+ end
333
+
334
+ # Okay, this part is as confusing as hell, so pay attention:
335
+ # This line determines the horizontal portion of the point on the circle where the X-Axis
336
+ # should end. It's caculated by taking the center of the on-image circle and adding that
337
+ # to the radius multiplied by the formula for determinig the point on a unit circle that a
338
+ # angle corresponds to. 3.6 * percent gives us that angle, but it's in degrees, so we need to
339
+ # convert, hence the muliplication by Pi over 180
340
+ arc_end_x = r + 2 + (r * Math.cos((3.6 * percent)*(Math::PI/180)))
341
+
342
+ # The same goes for here, except it's the vertical point instead of the horizontal one
343
+ arc_end_y = r + 2 + (r * Math.sin((3.6 * percent)*(Math::PI/180)))
344
+
345
+ # Because the SVG path format is seriously screwy, we need to set the large-arc-flag to 1
346
+ # if the angle of an arc is greater than 180 degrees. I have no idea why this is, but it is.
347
+ percent > 50? large_arc_flag = 1: large_arc_flag = 0
348
+
349
+ # This is also confusing
350
+ # M tells us to move to an absolute point on the image. We're moving to the center of the pie
351
+ # h tells us to move to a relative point. We're moving to the right edge of the circle.
352
+ # A tells us to start an absolute elliptical arc. The first two values are the radii of the ellipse
353
+ # the third value is the x-axis-rotation (how to rotate the ellipse if we wanted to [could have some fun
354
+ # with randomizing that maybe), the fourth value is our large-arc-flag, the fifth is the sweep-flag,
355
+ # (again, confusing), the sixth and seventh values are the end point of the arc which we calculated previously
356
+ # More info on the SVG path string format at: http://www.w3.org/TR/SVG/paths.html
357
+ path = "M#{r + 2},#{r + 2} h#{r} A#{r},#{r} 0 #{large_arc_flag},1 #{arc_end_x},#{arc_end_y} z"
358
+ @draw.path(path)
359
+
360
+ @draw.draw(@canvas)
361
+ @canvas.to_blob
362
+ end
363
+
364
+
365
+ # Creates a smooth sparkline.
366
+ #
367
+ # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
368
+ #
369
+ # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
370
+ #
371
+ # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false.
372
+ #
373
+ # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false.
374
+ #
375
+ # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false.
376
+ #
377
+ # :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.
378
+ #
379
+ # :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.
380
+ #
381
+ # :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.
382
+ def smooth
383
+
384
+ step = @options[:step].to_i
385
+ height = @options[:height].to_i
386
+ background_color = @options[:background_color]
387
+
388
+ create_canvas((@norm_data.size - 1) * step + 4, height, background_color)
389
+
390
+ min_color = @options[:min_color]
391
+ max_color = @options[:max_color]
392
+ last_color = @options[:last_color]
393
+ has_min = @options[:has_min]
394
+ has_max = @options[:has_max]
395
+ has_last = @options[:has_last]
396
+ line_color = @options[:line_color]
397
+
398
+ @draw.stroke(line_color)
399
+ coords = []
400
+ i=0
401
+ @norm_data.each do |r|
402
+ coords.push [ 2 + i, (height - 3 - r/(101.0/(height-4))) ]
469
403
  i += step
470
404
  end
405
+
406
+ open_ended_polyline(coords)
407
+
408
+ drawbox(coords[@norm_data.index(@norm_data.min)], 2, min_color) if has_min == true
409
+
410
+ drawbox(coords[@norm_data.index(@norm_data.max)], 2, max_color) if has_max == true
411
+
412
+ drawbox(coords[-1], 2, last_color) if has_last == true
413
+
414
+ @draw.draw(@canvas)
415
+ @canvas.to_blob
416
+ end
417
+
418
+
419
+ # Draw the error Sparkline.
420
+ def plot_error(options={})
421
+ create_canvas(40, 15, 'white')
422
+
423
+ @draw.fill('red')
424
+ @draw.line(0,0,40,15)
425
+ @draw.line(0,15,40,0)
426
+
427
+ @draw.draw(@canvas)
428
+ @canvas.to_blob
429
+ end
471
430
 
472
- draw.draw(img)
473
- img.to_blob
431
+ private
432
+
433
+ def normalize_data
434
+ @maximum_value = @data.max
435
+ if @options[:type].to_s == 'pie'
436
+ @norm_data = @data
437
+ else
438
+ @norm_data = @data.map { |value| value = (value.to_f / @maximum_value) * 100.0 }
439
+ end
474
440
  end
475
441
 
442
+ # * arr - an array of points (represented as two element arrays)
443
+ def open_ended_polyline(arr)
444
+ 0.upto(arr.length - 2) { |i|
445
+ @draw.line(arr[i][0], arr[i][1], arr[i+1][0], arr[i+1][1])
446
+ }
447
+ end
476
448
 
477
- def self.get_max_value(values=[])
478
- max_value = 0
479
- values.each do |value|
480
- max_value = (value > max_value) ? value : max_value
449
+ # Create an image to draw on and a drawable to do the drawing with.
450
+ #
451
+ # TODO Refactor into smaller functions
452
+ def create_canvas(w, h, bkg_col)
453
+ @draw = Magick::Draw.new
454
+ @draw.pointsize = @@pointsize # TODO Use height
455
+ @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
456
+
457
+ # Make room for label and last value
458
+ if !@options[:label].nil?
459
+ @options[:has_last] = true
460
+ @label_width = calculate_width(@options[:label])
461
+ @data_last_width = calculate_width(@data.last)
462
+ # HACK The 7.0 is a severe hack. Must figure out correct spacing
463
+ @label_and_data_last_width = @label_width + @data_last_width + @@label_margin * 7.0
464
+ w += @label_and_data_last_width
465
+ end
466
+
467
+ @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
468
+ @canvas.format = "PNG"
469
+
470
+ # Draw label and last value
471
+ if !@options[:label].nil?
472
+ if ENV.has_key?('MAGICK_FONT_PATH')
473
+ vera_font_path = File.expand_path('Vera.ttf', ENV['MAGICK_FONT_PATH'])
474
+ @font = File.exists?(vera_font_path) ? vera_font_path : nil
475
+ else
476
+ @font = nil
477
+ end
478
+
479
+ @draw.fill = 'black'
480
+ @draw.font = @font if @font
481
+ @draw.gravity = Magick::WestGravity
482
+ @draw.annotate( @canvas,
483
+ @label_width, 1.0,
484
+ w - @label_and_data_last_width + @@label_margin, h - calculate_caps_height/2.0,
485
+ @options[:label])
486
+
487
+ @draw.fill = 'red'
488
+ @draw.annotate( @canvas,
489
+ @data_last_width, 1.0,
490
+ w - @data_last_width - @@label_margin * 2.0, h - calculate_caps_height/2.0,
491
+ @data.last.to_s)
481
492
  end
482
- return max_value
483
493
  end
484
494
 
495
+ # Utility to draw a coloured box
496
+ # Centred on pt, offset off in each direction, fill color is col
497
+ def drawbox(pt, offset, color)
498
+ @draw.stroke 'transparent'
499
+ @draw.fill(color)
500
+ @draw.rectangle(pt[0]-offset, pt[1]-offset, pt[0]+offset, pt[1]+offset)
501
+ end
502
+
503
+ def calculate_width(text)
504
+ @draw.get_type_metrics(@canvas, text.to_s).width
505
+ end
506
+
507
+ def calculate_caps_height
508
+ @draw.get_type_metrics(@canvas, 'X').height
509
+ end
510
+
511
+
485
512
  end
data/rakefile CHANGED
@@ -27,6 +27,17 @@ task :clean do
27
27
  rm_rf "pkg"
28
28
  end
29
29
 
30
+ # Build a graphic file by running a single test.
31
+ #
32
+ # rake bar_extreme_values.png
33
+ # => Runs test_extreme_values
34
+
35
+ rule ".png" do |t|
36
+ test_name = t.name.gsub(/\.png/, '')
37
+ Rake::Task[:clean].invoke
38
+ sh "ruby -Ilib:test test/all_test.rb -n /^test_#{test_name}/"
39
+ end
40
+
30
41
  # Run the unit tests
31
42
  Rake::TestTask.new { |t|
32
43
  t.libs << "test"
@@ -76,6 +87,11 @@ Rake::GemPackageTask.new(spec) do |p|
76
87
  p.need_zip = true
77
88
  end
78
89
 
90
+ desc "Hackish copy to sparklines plugin for Rails"
91
+ task :update_plugin do
92
+ cp "lib/sparklines.rb", "../plugins/sparklines/lib/sparklines.rb"
93
+ end
94
+
79
95
  desc "Publish the API documentation"
80
96
  task :pgem => [:package] do
81
97
  Rake::SshFilePublisher.new("boss@topfunky.com", "public_html/gems/gems", "pkg", "#{PKG_FILE_NAME}.gem").upload
data/test/all_test.rb CHANGED
@@ -6,71 +6,116 @@ require 'lib/sparklines'
6
6
  class SparklinesTest < Test::Unit::TestCase
7
7
 
8
8
  def setup
9
- @data = (1..30).map { rand 100 }
9
+ @output_dir = "test/output"
10
+ @data = %w( 1 5 15 20 30 50 57 58 55 48
11
+ 44 43 42 42 46 48 49 53 55 59
12
+ 60 65 75 90 105 106 107 110 115 120
13
+ 115 120 130 140 150 160 170 100 100 10).map {|i| i.to_f}
10
14
  end
11
15
 
12
16
  def test_each_graph
13
17
  %w{pie area discrete smooth bar}.each do |type|
14
- Sparklines.plot_to_file("test/output/#{type}.png", @data, :type => type)
18
+ quick_graph("#{type}", :type => type)
15
19
  end
16
20
  end
17
21
 
18
- def test_pie_graph
22
+ def test_each_graph_with_label
23
+ %w{pie area discrete smooth bar}.each do |type|
24
+ quick_graph("labeled_#{type}", :type => type, :label => 'Comments for you')
25
+ end
26
+ end
27
+
28
+ def test_pie
19
29
  # Test extremes which previously did not work right
20
30
  [0, 1, 45, 95, 99, 100].each do |value|
21
- Sparklines.plot_to_file("test/output/pie#{value}.png", [value], :type => 'pie', :diameter => 128)
22
- end
31
+ Sparklines.plot_to_file("#{@output_dir}/pie#{value}.png",
32
+ value,
33
+ :type => 'pie',
34
+ :diameter => 128)
35
+ end
36
+ Sparklines.plot_to_file("#{@output_dir}/pie_flat.png",
37
+ [60],
38
+ :type => 'pie')
23
39
  end
24
40
 
25
- def test_special
26
- # Run special tests
27
- tests = { 'smooth-colored' => { :type => 'smooth', :line_color => 'purple'},
28
- 'pie-large' => { :type => 'pie', :diameter => 200 },
29
- 'area-high' => { :type => 'area',
30
- :upper => 80,
31
- :step => 4,
32
- :height => 20},
33
- 'discrete-wide' => { :type => 'discrete',
34
- :step => 8 },
35
- 'bar-wide' => { :type => 'bar',
36
- :step => 8 }
41
+ def test_special_conditions
42
+ tests = { 'smooth_colored' => {
43
+ :type => 'smooth',
44
+ :line_color => 'purple'
45
+ },
46
+ 'pie_large' => {
47
+ :type => 'pie',
48
+ :diameter => 200
49
+ },
50
+ 'area_high' => {
51
+ :type => 'area',
52
+ :upper => 80,
53
+ :step => 4,
54
+ :height => 20
55
+ },
56
+ 'discrete_wide' => {
57
+ :type => 'discrete',
58
+ :step => 8
59
+ },
60
+ 'bar_wide' => {
61
+ :type => 'bar',
62
+ :step => 8
63
+ },
64
+ 'bar_tall' => {
65
+ :type => 'bar',
66
+ :below_color => 'blue',
67
+ :above_color => 'red',
68
+ :upper => 90,
69
+ :height => 50,
70
+ :step => 8
71
+ }
37
72
  }
38
- tests.keys.each do |key|
39
- Sparklines.plot_to_file("test/output/#{key}.png", @data, tests[key])
73
+ tests.each do |name, options|
74
+ quick_graph(name, options)
40
75
  end
41
76
  end
42
77
 
43
- def test_smooth_graph
44
-
78
+ # def test_smooth_graph
79
+ #
80
+ # end
81
+
82
+ def test_bar_extreme_values
83
+ Sparklines.plot_to_file("#{@output_dir}/bar_extreme_values.png",
84
+ [0,1,100,2,99,3,98,4,97,5,96,6,95,7,94,8,93,9,92,10,91],
85
+ :type => 'bar',
86
+ :below_color => 'blue',
87
+ :above_color => 'red',
88
+ :upper => 90,
89
+ :step => 4 )
45
90
  end
46
91
 
47
- def test_bar_tall
48
- Sparklines.plot_to_file('test/output/bar-tall.png', @data,
49
- :type => 'bar',
50
- :below_color => 'blue',
51
- :above_color => 'red',
52
- :upper => 90,
53
- :height => 50,
54
- :step => 8 )
92
+ def test_string_args
93
+ quick_graph("bar_string.png",
94
+ 'type' => 'bar',
95
+ 'below_color' => 'blue',
96
+ 'above_color' => 'red',
97
+ 'upper' => 50,
98
+ 'height' => 50,
99
+ 'step' => 8 )
55
100
  end
56
101
 
57
- def test_bar_extreme_values
58
- Sparklines.plot_to_file('test/output/bar-extreme-values.png', [0,1,100,2,99,3,98,4,97,5,96,6,95,7,94,8,93,9,92,10,91],
59
- :type => 'bar',
60
- :below_color => 'blue',
61
- :above_color => 'red',
62
- :upper => 90,
63
- :step => 4 )
102
+ def test_area_min_max
103
+ quick_graph("area_min_max",
104
+ :has_min => true,
105
+ :has_max => true,
106
+ :has_first => true,
107
+ :has_last => true)
64
108
  end
65
109
 
66
- def test_string_args
67
- Sparklines.plot_to_file('test/output/bar-string.png', @data,
68
- 'type' => 'bar',
69
- 'below_color' => 'blue',
70
- 'above_color' => 'red',
71
- 'upper' => 50,
72
- 'height' => 50,
73
- 'step' => 8 )
110
+
111
+ def test_no_type
112
+ Sparklines.plot_to_file("#{@output_dir}/error.png", 0, :type => 'nonexistent')
113
+ end
114
+
115
+ private
116
+
117
+ def quick_graph(name, options)
118
+ Sparklines.plot_to_file("#{@output_dir}/#{name}.png", @data, options)
74
119
  end
75
120
 
76
121
  end
metadata CHANGED
@@ -3,8 +3,8 @@ rubygems_version: 0.8.11
3
3
  specification_version: 1
4
4
  name: sparklines
5
5
  version: !ruby/object:Gem::Version
6
- version: 0.2.7
7
- date: 2006-05-17 00:00:00 -07:00
6
+ version: 0.4.0
7
+ date: 2006-07-31 00:00:00 -07:00
8
8
  summary: Tiny graphs for concise data.
9
9
  require_paths:
10
10
  - lib