Dynamic Stat Graphs in Rails

What started out as a 'quick feature' turned out to be a fun Ruby endeavor - below is a guide with some neat Ruby tricks to create dynamic stat graphs (sample above) with the help of Gruff library. In my visual database explorer article I talked about generating SVG graphs with the help of Scruffy. However, SVG is a nightmare when it comes to browser compatibility issues and inline support, hence in this instance we will settle for RMagick on the backend.
First off, if you haven't already, take a look at the Gruff RDoc - there is a lot of features hidden under the covers! Assuming you have the gem and the plugin installed, let's get right to it. First the code, then the explanations:
def stats
g = Gruff::Line.new('580x210')
g.theme = {
:colors => ['#ff6600', '#3bb000', '#1e90ff', '#efba00', '#0aaafd'],
:marker_color => '#aaa',
:background_colors => ['#eaeaea', '#fff']
}
g.hide_title = true
g.font = File.expand_path('path/to/font.ttf', RAILS_ROOT)
range = "created_at #{(12.months.ago.to_date..Date.today).to_s(:db)}"
@users = User.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")
@votes = Vote.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")
@bookmarks = Bookmark.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")
# Take the union of all keys & convert into a hash {1 => "month", 2 => "month2"...}
# - This will be the x-axis.. representing the date range
months = (@users.keys | @votes.keys | @bookmarks.keys).sort
keys = Hash[*months.collect {|v| [months.index(v),v.to_s] }.flatten]
# Plot the data - insert 0's for missing keys
g.data("Users", keys.collect {|k,v| @users[v].nil? ? 0 : @users[v]})
g.data("Votes", keys.collect {|k,v| @votes[v].nil? ? 0 : @votes[v]})
g.data("Bookmarks", keys.collect {|k,v| @bookmarks[v].nil? ? 0 : @bookmarks[v]})
g.labels = keys
send_data(g.to_blob, :disposition => 'inline', :type => 'image/png', :filename => "site-stats.png")
end
First few lines should be self-explanatory, we specify custom geometry, our theme colors, hide the title, and finally indicate the font we would like to use in the image. (Note: If RMagick complains about being unable to measure the font-sizes, make sure you have 'freetype-fonts' installed on your system) Next, is the SQL aggregation code, here we want to count the number of new users, votes and bookmarks on monthly basis:
range = "created_at #{(12.months.ago.to_date..Date.today).to_s(:db)}"
# .to_s(:db) is a helper to transform a date-range into SQL condition clause
# ex. above: => "created_at BETWEEN '2006-01-05' AND '2007-01-05'"
@users = User.count(:all, :conditions => range, :group => "DATE_FORMAT(created_at, '%Y-%m')", :order =>"created_at ASC")
# - count all users, whose created_at timestamp falls within the range specified above
# - group users by 'Year-Month', and count the number of occurences within each group
# ex. result:
# count | date
# ---------------
# 2 | 2005-10
# 24 | 2005-11
# 32 | 2005-12
There is one catch, if we have no new records in one of the intervals, than the count will be missing from the returned OrderedHash - we need to guard against this by injecting '0' counts for the missing intervals:
# first, we will take the union of the date arrays and sort our results
# ex: [1,2] | [2,3] => [1, 2, 3]
months = (@users.keys | @votes.keys | @bookmarks.keys).sort
# This is a tricky one. For the x-axis labels, Gruff requires a hash in the form of:
# { 1 => "label", 2=> "label2" ... }
# Hence, we have a 'months' array, which we need to convert it into a hash.
# 1) * is the splat operator - it will split our array and pass each value separately to the block
# - give it a try, in console do: p *[1,2,3]
# 2) Next, we will iterate over every value returned by the * operator and
# replace it with a [index, value] pair
# - ex: [a,b,c] => [[1,a],[2,b],[3,c]]
# 3) Now we flatten the resulting array and obtain:
# [[1,a],[2,b],[3,c]].flatten => [1,a,2,b,3,c]
# 4) Result is passed to the hash constructor, which converts every tuple
# into a key => value pair. Giving us:
# Hash[[1,a,2,b,3,c]] => { 1 => 'a', 2 => 'b', 3 => 'c' }
#
# Phew..
keys = Hash[*months.collect {|v| [months.index(v),v.to_s] }.flatten]
# Now we can iterate over all keys ans insert the user values for the months
# that have a count, and otherwise provide a '0'.
g.data("Users", keys.collect {|k,v| @users[v].nil? ? 0 : @users[v]})
Almost done. After assigning the x-axis labels we call send_data to stream the resulting PNG image directly to the browser. Now all we have to do is embed our image into a page, or call it directly:
<p align="center">
<img src="<%= url_for :controller => "admin", :action=> "stats" %>" style="border:1px solid #aabcca;" />
</p>
After adding a quick CSS border and centering the image, you can feast your eyes on the latest trends of your paradigm-shifting application. For performance reasons, I would also recommend caching the action if you plan to display your results in a high-traffic area. For my personal use, I am only showing the trends in a private administration section (shown on the left). I can afford to regenerate the image on every refresh.
For more ideas and Ruby graphing tutorials take a look at: Visual Database Explorer and Dynamic Graphics in Rails 1.2.
Ilya Grigorik is a web performance engineer and developer advocate on the Make The Web Fast team at Google, where he spends his days and nights on making the web fast and driving adoption of performance best practices.
Follow @igrigorik