7 A library for generating small unmarked graphs (sparklines).
9 Can be used to write an image to a file or make a web service with Rails or other Ruby CGI apps.
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].
13 Requires the RMagick image library.
17 {Dan Nugent}[mailto:nugend@gmail.com] Original port from Python Sparklines library.
19 {Geoffrey Grosenbach}[mailto:boss@topfunky.com] -- http://nubyonrails.topfunky.com
20 -- Conversion to module and further maintenance.
22 ==General Usage and Defaults
28 Sparklines.plot([1,25,33,46,89,90,85,77,42],
32 An image blob will be returned which you can print, write to STDOUT, etc.
34 For use with Ruby on Rails, see the sparklines plugin:
36 http://nubyonrails.com/pages/sparklines
38 In your view, call it like this:
40 <%= sparkline_tag [1,2,3,4,5,6] %>
44 <%= sparkline_tag [1,2,3,4,5,6],
48 :above_color => 'green',
49 :below_color => 'blue' %>
66 :below_color => 'grey'
67 :background_color => 'white'
68 :line_color => 'lightgrey'
72 Licensed under the MIT license.
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={})
94 :line_color => 'lightgrey',
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',
112 # HACK for HashWithIndifferentAccess
113 options_sym = Hash.new
114 options.keys.each do |key|
115 options_sym[key.to_sym] = options[key]
118 options_sym = defaults.merge(options_sym)
120 # Call the appropriate method for actual plotting.
121 sparkline = self.new(data, options_sym)
122 if %w(area bar pie smooth discrete whisker).include? options_sym[:type]
123 sparkline.send options_sym[:type]
125 sparkline.plot_error options_sym
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)
138 def initialize(data=[], options={})
145 # Creates a continuous area sparkline. Relevant options.
147 # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
149 # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
151 # :upper - An integer that determines the threshold for colorization purposes. Any value less than upper will be colored using below_color, anything above and equal to upper will use above_color. Defaults to 50.
153 # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false.
155 # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false.
157 # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false.
159 # :min_color - A string or color code representing the color that the dot drawn at the smallest value will be displayed as. Defaults to blue.
161 # :max_color - A string or color code representing the color that the dot drawn at the largest value will be displayed as. Defaults to green.
163 # :last_color - A string or color code representing the color that the dot drawn at the last value will be displayed as. Defaults to red.
165 # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
167 # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
171 step = @options[:step].to_i
172 height = @options[:height].to_i
173 background_color = @options[:background_color]
175 create_canvas((@norm_data.size - 1) * step + 4, height, background_color)
177 upper = @options[:upper].to_i
179 has_min = @options[:has_min]
180 has_max = @options[:has_max]
181 has_last = @options[:has_last]
183 min_color = @options[:min_color]
184 max_color = @options[:max_color]
185 last_color = @options[:last_color]
186 below_color = @options[:below_color]
187 above_color = @options[:above_color]
190 coords = [[0,(height - 3 - upper/(101.0/(height-4)))]]
192 @norm_data.each do |r|
193 coords.push [(2 + i), (height - 3 - r/(101.0/(height-4)))]
196 coords.push [(@norm_data.size - 1) * step + 4, (height - 3 - upper/(101.0/(height-4)))]
198 # TODO Refactor! Should take a block and do both.
200 # Block off the bottom half of the image and draw the sparkline
201 @draw.fill(above_color)
202 @draw.define_clip_path('top') do
203 @draw.rectangle(0,0,(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
205 @draw.clip_path('top')
206 @draw.polygon *coords.flatten
208 # Block off the top half of the image and draw the sparkline
209 @draw.fill(below_color)
210 @draw.define_clip_path('bottom') do
211 @draw.rectangle(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,height)
213 @draw.clip_path('bottom')
214 @draw.polygon *coords.flatten
216 # The sparkline looks kinda nasty if either the above_color or below_color gets the center line
218 @draw.line(0,(height - 3 - upper/(101.0/(height-4))),(@norm_data.size - 1) * step + 4,(height - 3 - upper/(101.0/(height-4))))
220 # After the parts have been masked, we need to let the whole canvas be drawable again
221 # so a max dot can be displayed
222 @draw.define_clip_path('all') do
223 @draw.rectangle(0,0,@canvas.columns,@canvas.rows)
225 @draw.clip_path('all')
227 drawbox(coords[@norm_data.index(@norm_data.min)+1], 1, min_color) if has_min == true
228 drawbox(coords[@norm_data.index(@norm_data.max)+1], 1, max_color) if has_max == true
230 drawbox(coords[-2], 1, last_color) if has_last == true
240 step = @options[:step].to_i
241 height = @options[:height].to_f
242 background_color = @options[:background_color]
244 create_canvas(@norm_data.length * step + 2, height, background_color)
246 upper = @options[:upper].to_i
247 below_color = @options[:below_color]
248 above_color = @options[:above_color]
251 @norm_data.each_with_index do |r, index|
252 color = (r >= upper) ? above_color : below_color
253 @draw.stroke('transparent')
255 @draw.rectangle( i, @canvas.rows,
256 i + step - 2, @canvas.rows - ( (r / @maximum_value) * @canvas.rows) )
266 # Creates a discretized sparkline
268 # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
270 # :upper - An integer that determines the threshold for colorization purposes. Any value less than upper will be colored using below_color, anything above and equal to upper will use above_color. Defaults to 50.
272 # :above_color - A string or color code representing the color to draw values above or equal the upper value. Defaults to red.
274 # :below_color - A string or color code representing the color to draw values below the upper value. Defaults to gray.
278 height = @options[:height].to_i
279 upper = @options[:upper].to_i
280 background_color = @options[:background_color]
281 step = @options[:step].to_i
283 create_canvas(@norm_data.size * step - 1, height, background_color)
285 below_color = @options[:below_color]
286 above_color = @options[:above_color]
289 @norm_data.each do |r|
290 color = (r >= upper) ? above_color : below_color
292 @draw.line(i, (@canvas.rows - r/(101.0/(height-4))-4).to_i,
293 i, (@canvas.rows - r/(101.0/(height-4))).to_i)
303 # Creates a pie-chart sparkline
305 # :diameter - An integer that determines what the size of the sparkline will be. Defaults to 20
307 # :share_color - A string or color code representing the color to draw the share of the pie represented by percent. Defaults to red.
309 # :remain_color - A string or color code representing the color to draw the pie not taken by the share color. Defaults to lightgrey.
312 diameter = @options[:diameter].to_i
313 background_color = @options[:background_color]
315 create_canvas(diameter, diameter, background_color)
317 share_color = @options[:share_color]
318 remain_color = @options[:remain_color]
319 percent = @norm_data[0]
321 # Adjust the radius so there's some edge left in the pie
323 @draw.fill(remain_color)
324 @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
325 @draw.fill(share_color)
329 # For 0% return blank
331 return @canvas.to_blob
333 # For 100% just draw a full circle
334 @draw.ellipse(r + 2, r + 2, r , r , 0, 360)
336 return @canvas.to_blob
339 # Okay, this part is as confusing as hell, so pay attention:
340 # This line determines the horizontal portion of the point on the circle where the X-Axis
341 # should end. It's caculated by taking the center of the on-image circle and adding that
342 # to the radius multiplied by the formula for determinig the point on a unit circle that a
343 # angle corresponds to. 3.6 * percent gives us that angle, but it's in degrees, so we need to
344 # convert, hence the muliplication by Pi over 180
345 arc_end_x = r + 2 + (r * Math.cos((3.6 * percent)*(Math::PI/180)))
347 # The same goes for here, except it's the vertical point instead of the horizontal one
348 arc_end_y = r + 2 + (r * Math.sin((3.6 * percent)*(Math::PI/180)))
350 # Because the SVG path format is seriously screwy, we need to set the large-arc-flag to 1
351 # if the angle of an arc is greater than 180 degrees. I have no idea why this is, but it is.
352 percent > 50? large_arc_flag = 1: large_arc_flag = 0
354 # This is also confusing
355 # M tells us to move to an absolute point on the image. We're moving to the center of the pie
356 # h tells us to move to a relative point. We're moving to the right edge of the circle.
357 # A tells us to start an absolute elliptical arc. The first two values are the radii of the ellipse
358 # the third value is the x-axis-rotation (how to rotate the ellipse if we wanted to [could have some fun
359 # with randomizing that maybe), the fourth value is our large-arc-flag, the fifth is the sweep-flag,
360 # (again, confusing), the sixth and seventh values are the end point of the arc which we calculated previously
361 # More info on the SVG path string format at: http://www.w3.org/TR/SVG/paths.html
362 path = "M#{r + 2},#{r + 2} h#{r} A#{r},#{r} 0 #{large_arc_flag},1 #{arc_end_x},#{arc_end_y} z"
371 # Creates a smooth sparkline.
373 # :step - An integer that determines the distance between each point on the sparkline. Defaults to 2.
375 # :height - An integer that determines what the height of the sparkline will be. Defaults to 14
377 # :has_min - Determines whether a dot will be drawn at the lowest value or not. Defaults to false.
379 # :has_max - Determines whether a dot will be drawn at the highest value or not. Defaults to false.
381 # :has_last - Determines whether a dot will be drawn at the last value or not. Defaults to false.
383 # :min_color - A string or color code representing the color that the dot drawn at the smallest value will be displayed as. Defaults to blue.
385 # :max_color - A string or color code representing the color that the dot drawn at the largest value will be displayed as. Defaults to green.
387 # :last_color - A string or color code representing the color that the dot drawn at the last value will be displayed as. Defaults to red.
391 step = @options[:step].to_i
392 height = @options[:height].to_i
393 background_color = @options[:background_color]
395 create_canvas((@norm_data.size - 1) * step + 4, height, background_color)
397 min_color = @options[:min_color]
398 max_color = @options[:max_color]
399 last_color = @options[:last_color]
400 has_min = @options[:has_min]
401 has_max = @options[:has_max]
402 has_last = @options[:has_last]
403 line_color = @options[:line_color]
405 @draw.stroke(line_color)
408 @norm_data.each do |r|
409 coords.push [ 2 + i, (height - 3 - r/(101.0/(height-4))) ]
413 open_ended_polyline(coords)
415 drawbox(coords[@norm_data.index(@norm_data.min)], 2, min_color) if has_min == true
417 drawbox(coords[@norm_data.index(@norm_data.max)], 2, max_color) if has_max == true
419 drawbox(coords[-1], 2, last_color) if has_last == true
427 # Creates a whisker sparkline to track on/off type data. There are five states:
428 # on, off, no value, exceptional on, exceptional off. On values create an up
429 # whisker and off values create a down whisker. Exceptional values may be
430 # colored differently than regular values to indicate, for example, a shut out.
431 # No value produces an empty row to indicate a tie.
433 # * results - an array of integer values between -2 and 2. -2 is exceptional
434 # down, 1 is regular down, 0 is no value, 1 is up, and 2 is exceptional up.
435 # * options - a hash that takes parameters
437 # :height - height of the sparkline
439 # :whisker_color - the color of regular whiskers; defaults to black
441 # :exception_color - the color of exceptional whiskers; defaults to red
445 # step = @options[:step].to_i
446 height = @options[:height].to_i
447 background_color = @options[:background_color]
449 create_canvas((@data.size - 1) * 2, height, background_color)
451 whisker_color = @options[:whisker_color] || 'black'
452 exception_color = @options[:exception_color] || 'red'
456 color = whisker_color
458 if ( (r == 2 || r == -2) && exception_color )
459 color = exception_color
462 y_mid_point = (r >= 1) ? (@canvas.rows/2.0 - 1).ceil : (@canvas.rows/2.0).floor
464 y_end_point = y_mid_point
470 y_end_point = @canvas.rows
473 @draw.stroke( color )
474 @draw.line( i, y_mid_point, i, y_end_point )
483 # Draw the error Sparkline.
485 def plot_error(options={})
486 create_canvas(40, 15, 'white')
489 @draw.line(0,0,40,15)
490 @draw.line(0,15,40,0)
499 @maximum_value = @data.max
500 if @options[:type].to_s == 'pie'
503 @norm_data = @data.map { |value| value = (value.to_f / @maximum_value) * 100.0 }
508 # * :arr - an array of points (represented as two element arrays)
510 def open_ended_polyline(arr)
511 0.upto(arr.length - 2) { |i|
512 @draw.line(arr[i][0], arr[i][1], arr[i+1][0], arr[i+1][1])
517 # Create an image to draw on and a drawable to do the drawing with.
519 # TODO Refactor into smaller methods
521 def create_canvas(w, h, bkg_col)
522 @draw = Magick::Draw.new
523 @draw.pointsize = @@pointsize # TODO Use height
524 @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
526 # Make room for label and last value
527 unless @options[:label].nil?
528 @options[:has_last] = true
529 @label_width = calculate_width(@options[:label])
530 @data_last_width = calculate_width(@data.last)
531 # HACK The 7.0 is a severe hack. Must figure out correct spacing
532 @label_and_data_last_width = @label_width + @data_last_width + @@label_margin * 7.0
533 w += @label_and_data_last_width
536 @canvas = Magick::Image.new(w , h) { self.background_color = bkg_col }
537 @canvas.format = "PNG"
539 # Draw label and last value
540 unless @options[:label].nil?
541 if ENV.has_key?('MAGICK_FONT_PATH')
542 vera_font_path = File.expand_path('Vera.ttf', ENV['MAGICK_FONT_PATH'])
543 @font = File.exists?(vera_font_path) ? vera_font_path : nil
549 @draw.font = @font if @font
550 @draw.gravity = Magick::WestGravity
551 @draw.annotate( @canvas,
553 w - @label_and_data_last_width + @@label_margin, h - calculate_caps_height/2.0,
557 @draw.annotate( @canvas,
558 @data_last_width, 1.0,
559 w - @data_last_width - @@label_margin * 2.0, h - calculate_caps_height/2.0,
565 # Utility to draw a coloured box
566 # Centred on pt, offset off in each direction, fill color is col
568 def drawbox(pt, offset, color)
569 @draw.stroke 'transparent'
571 @draw.rectangle(pt[0]-offset, pt[1]-offset, pt[0]+offset, pt[1]+offset)
574 def calculate_width(text)
575 @draw.get_type_metrics(@canvas, text.to_s).width
578 def calculate_caps_height
579 @draw.get_type_metrics(@canvas, 'X').height