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.
- data/CHANGELOG +74 -0
- data/MIT-LICENSE +21 -0
- data/Manifest.txt +64 -0
- data/README.txt +20 -0
- data/Rakefile +22 -0
- data/lib/sparklines.rb +785 -0
- data/lib/sparklines_helper.rb +28 -0
- data/samples/area-high.png +0 -0
- data/samples/area.png +0 -0
- data/samples/discrete.png +0 -0
- data/samples/pie-large.png +0 -0
- data/samples/pie.png +0 -0
- data/samples/pie0.png +0 -0
- data/samples/pie1.png +0 -0
- data/samples/pie100.png +0 -0
- data/samples/pie45.png +0 -0
- data/samples/pie95.png +0 -0
- data/samples/pie99.png +0 -0
- data/samples/smooth-colored.png +0 -0
- data/samples/smooth.png +0 -0
- data/test/expected/area.png +0 -0
- data/test/expected/area_high.png +0 -0
- data/test/expected/area_min_max.png +0 -0
- data/test/expected/bar.png +0 -0
- data/test/expected/bar_extreme_values.png +0 -0
- data/test/expected/bar_string.png.png +0 -0
- data/test/expected/bar_tall.png +0 -0
- data/test/expected/bar_wide.png +0 -0
- data/test/expected/bullet_basic.png +0 -0
- data/test/expected/bullet_colorful.png +0 -0
- data/test/expected/bullet_full_featured.png +0 -0
- data/test/expected/bullet_tall.png +0 -0
- data/test/expected/bullet_wide.png +0 -0
- data/test/expected/discrete.png +0 -0
- data/test/expected/discrete_wide.png +0 -0
- data/test/expected/error.png +0 -0
- data/test/expected/labeled_area.png +0 -0
- data/test/expected/labeled_bar.png +0 -0
- data/test/expected/labeled_discrete.png +0 -0
- data/test/expected/labeled_pie.png +0 -0
- data/test/expected/labeled_smooth.png +0 -0
- data/test/expected/labeled_whisker_decimals.png +0 -0
- data/test/expected/pie.png +0 -0
- data/test/expected/pie0.png +0 -0
- data/test/expected/pie1.png +0 -0
- data/test/expected/pie100.png +0 -0
- data/test/expected/pie45.png +0 -0
- data/test/expected/pie95.png +0 -0
- data/test/expected/pie99.png +0 -0
- data/test/expected/pie_flat.png +0 -0
- data/test/expected/pie_large.png +0 -0
- data/test/expected/smooth.png +0 -0
- data/test/expected/smooth_colored.png +0 -0
- data/test/expected/smooth_similar_nonzero_values.png +0 -0
- data/test/expected/smooth_underneath_color.png +0 -0
- data/test/expected/smooth_with_target.png +0 -0
- data/test/expected/standard_deviation.png +0 -0
- data/test/expected/standard_deviation_short.png +0 -0
- data/test/expected/standard_deviation_tall.png +0 -0
- data/test/expected/whisker.png +0 -0
- data/test/expected/whisker_junk.png +0 -0
- data/test/expected/whisker_non_exceptional.png +0 -0
- data/test/expected/whisker_with_step.png +0 -0
- data/test/test_all.rb +302 -0
- 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
|