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.