Thursday, August 26, 2010

Drawing trees with Raphael.js and Ruby

It's been quite some time since I last posted (I just realise I open every post like this - guess I'm one of the laziest bloggers out there :-).

I've been busy hacking Ruby and thought I share this. For a website I'm currently building, I needed to draw organisation and process hierarchies (ie. any type of tree or forest) in the browser. the drawings need to be interactive (ie. clickable for getting a node's details).

First challenge was how to do the drawing. Generate the image server side, but then what about clickable areas in the image? Draw the image client side, but then I really didn't want to implement a tree drawing algorithm in JavaScript...

I did some research about what's out there in the wild and came across Raphael.js (kudos Dmitry, great job!). Raphael is a clean, simple graphics API for JavaScript.

But I still didn't want to implement tree drawing in JavaScript, so came to the conclusion to implement the tree-drawing in Ruby so as to generate the JavaScript.

The result is further down in this post. TreeDraw is a class to which you pass a tree or forest (I'm using acts_as_tree to get methods like children, ancestors, parent) as well as some drawing options (optional, defaults are all defined).

You can also provide a base URL (e.g. "/mycontroller/myaction/") which will be augmented with a node's ID to become a link to viewing a node's details (e.g. "/mycontroller/myaction/5").

The code looks like this:


# The TreeDraw class draws trees using the Raphael Javascript drawing library.
class TreeDraw

include ActionView::Helpers::TextHelper
include ERB::Util

# Construct a new Tree drawing instance following the Reingold-Tilford-Algorithm. Parameters are:
# - roots => The forest to draw (array of roots of the forest)
# - link => If specified, then each box will link to the specified link with the id of the specific node appended
# - options => a hash allowing to override the various default drawing options
def initialize(roots, link=nil, options = {})
@roots = roots # the roots of the tree where each node responds to methods like "children", "parent", "ancestors"
@link = link
init_options(options)
@x_pos = {} # hash to hold the x positions for each node
@x_mod = {} # hash to hold the x position modifiers for each node
@x_cumul_mod = {}
@y_pos = {} # hash to hold the y positions for each node
@max_width = {} # hash to hold the max width of each root's tree
@connectors = [] # the svg path strings for all connectors in the tree
@completed = {}
@total_width = 0 # total width of drawing canvas adopted to tree size
@total_height = @options[:box_height] + @options[:v_padding] # total height of drawing canvas adopted to tree size, initialised to height of one box
end

# Return the Javascript required to draw the tree/forest at
# the top left of the specified dom element id
def draw(dom_element_id)
calculate
return generate_code(dom_element_id)
end

protected

# Return the root for the specified node
def root_for(node)
if @roots.member?(node)==true
return node
end
node.ancestors.each do |ancestor|
if @roots.member?(ancestor)==true
return ancestor
end
end
end

# Generate the SVG paths for connecting the specified node with its ancestor
def connect(node)
offset = @options[:box_width]/2
if node.children.size > 0
@connectors << "M#{@x_pos[node]+offset} #{@y_pos[node]+@options[:box_height]} L#{@x_pos[node]+offset} #{@y_pos[node]+@options[:box_height]+(@options[:v_padding]/2)}"
@connectors << "M#{@x_pos[node.children.first]+offset} #{@y_pos[node]+@options[:box_height]+(@options[:v_padding]/2)} L#{@x_pos[node.children.last]+offset} #{@y_pos[node]+@options[:box_height]+(@options[:v_padding]/2)}"
end
node.children.each do |child|
@connectors << "M#{@x_pos[child]+offset} #{@y_pos[child]-(@options[:v_padding]/2)} L#{@x_pos[child]+offset} #{@y_pos[child]}"
connect(child)
end
end

# Return the level of this node in the tree
def level(node)
root = root_for(node)
return (node.ancestors().size() - root.ancestors().size()) + 1
end

# Layer all nodes in the appropriate y-coordinates
def layer(node)
l = level(node)
@y_pos[node] = ((l - 1) * @options[:box_height]) + ((l - 1) * @options[:v_padding])
if @y_pos[node] > @total_height
@total_height = @y_pos[node]
end
node.children.each do |child|
layer(child)
end
end

# Return all nodes of the tree/forest on the specified level (regardless of whether these nodes are related)
def nodes_for_level(level, node = nil)
nodes = []
if (level == 1)
return @roots
end
if node == nil
@roots.each do |node|
nodes += nodes_for_level(level, node)
end
else
if level==(level(node)+1)
nodes += node.children
else
node.children.each do |child|
nodes += nodes_for_level(level, child)
end
end
end
return nodes
end

# Return the left sibling of this node (note that "sibling" does not mean that the two nodes
# belong to the same ancestor)
def left_sibling(node)
siblings = nodes_for_level(level(node))
if siblings
index = siblings.index(node)
if (index > 0)
return siblings[index-1]
end
end
return nil
end

# Shift the specified node with respect to its left sibling.
def shift_beyond(node)
x = @x_pos[node]
x += @x_mod[node]
node.ancestors.each do |ancestor|
if @x_mod[ancestor]==nil
return x
else
x += @x_mod[ancestor]
end
end
return x
end

# Position all nodes in the tree/forest
def calculate()
@roots.each do |root|
first_pass(root)
second_pass(root)
layer(root)
connect(root)
if @total_width < @max_width[root]
@total_width = @max_width[root]
end
end
end

# First pass placing the nodes horizontally.
def first_pass(node)
# first pass is post order, depth first
node.children.each do |child|
first_pass(child)
end
mod = 0
x_pos = 0
children = node.children
if (children.size == 0) # leaf node
left_sibling = left_sibling(node)
if left_sibling # leaf node with left sibling
x_pos = shift_beyond(left_sibling) + @options[:box_width] + @options[:h_padding]
end
else # node with children
left_most_child = children.first
right_most_child = children.last
x_pos = (@x_pos[left_most_child] + @x_mod[left_most_child] + @x_pos[right_most_child] + @x_mod[right_most_child])/2
left_sibling = left_sibling(node)
if left_sibling # node with left sibling
mod = [(@options[:box_width] + @options[:h_padding]) - (x_pos - shift_beyond(left_sibling)), 0].max
end
end
@x_pos[node] = x_pos
@x_mod[node] = mod
@x_cumul_mod[node] = 0
root = root_for(node)
@max_width[root] ||= 0
if (@x_pos[node]+@options[:box_width]) > @max_width[root]
@max_width[root] = @x_pos[node]+@options[:box_width]
end
end

# Second pass shifting the nodes horizontally according to the position of their left siblings.
def second_pass(node)
@x_pos[node] += @x_mod[node] + @x_cumul_mod[node]
node.children.each do |child|
@x_cumul_mod[child] = @x_mod[node] + @x_cumul_mod[node]
end
node.children.each do |child|
second_pass(child)
end
end

# Once the nodes have been positioned horizontally and vertically, generate the Raphael.js Javascript
# to draw the tree in the browser
def generate_code(dom_element_id)
result = ""
result += ""
end

# Generate the box and text for a node.
def generate(node)
result = generate_box(node)
result += generate_text(node)
node.children.each do |child|
result += generate(child)
end
return result
end

# Draw the box and corresponding mouseover (for domTT popups).
def generate_box(node)
result = "t=paper.rect(#{@x_pos[node]}, #{@y_pos[node]}, #{@options[:box_width]}, #{@options[:box_height]}, #{@options[:box_round_edge]});"
result += "t.attr({stroke:'#{@options[:box_color]}',fill:'#{@options[:box_color]}'});"
result += "t.node.onmouseover=function(event){domTT_activate(this, event, 'content', '#{node.description.gsub(/\n/,"\\\n").gsub(/\r/,"\\\r")}', 'styleClass', 'tooltip')};"
result += "t=paper.rect(#{@x_pos[node]+@options[:shadow_box_offset]}, #{@y_pos[node]+@options[:shadow_box_offset]}, #{@options[:box_width]}, #{@options[:box_height]}, #{@options[:box_round_edge]});"
result += "t.attr({fill:'#{@options[:shadow_box_color]}',stroke:'#{@options[:shadow_box_color]}'});"
result += "t.toBack();"
return result
end

# Generate the node's text in the drawing as well as a link to displaying details about the node (if a link was specified upon construction) using node's id.
def generate_text(node)
length = node.name.size
result = "t=paper.text(#{@x_pos[node]}, #{@y_pos[node]}, '#{truncate(node.name, :length => @options[:max_text_length], :ommission => "...")}\\n(#{truncate((node.reference ? node.reference : ""), :length => @options[:max_text_length], :ommission => "...")})\\n#{truncate((node.person ? node.person.full_name : ""), :length => @options[:max_text_length], :ommission => "...")}');"
result += "t.attr({'text-anchor': 'middle', 'font-size': #{@options[:font_size]}, 'font-weight': '#{@options[:font_weight]}', 'font-family': '#{@options[:font_family]}', fill:'#{@options[:text_color]}'});"
result += "t.translate(((#{@options[:box_width]} - t.getBBox().width)/2)+t.getBBox().width/2, #{@options[:box_height]}/2);"
if (@link)
result += "t.hover(function(){this.attr({fill: '#{@options[:highlight_color]}'});},function(){this.attr({fill: '#{@options[:text_color]}'});});"
result += "t.click(function(){location='#{@link}/#{node.id}'});"
end
return result;
end

# Generate the connecting lines between nodes.
def generate_connectors
result ="t=paper.path('"
@connectors.each do |path|
result += " #{path}"
end
result += "');t.attr({stroke:'#{@options[:line_color]}'});"
return result
end

# Initialise the options using defaults or the overrides specified upon construction of this object
def init_options(options)
@options = options
@options[:box_width] ||= 200 # width of a box representing a node
@options[:box_height] ||= 50 # height of a box representing a node
@options[:tree_padding] ||= 50 # horizontal padding between trees of the forest
@options[:v_padding] ||= 50 # vertical padding between all nodes of the forest
@options[:h_padding] ||= 40 # horizontal padding between nodes of the same tree in the forest
@options[:box_round_edge] ||= 5 # radius of round edges (0 for normal edges)
@options[:box_color] ||= "#9aafe5" # fill color of a box representing a node
@options[:highlight_color] ||= "#2e6ab1" # highlight color of the text if there's a link
@options[:shadow_box_color] ||= "#0e509e" # color of the shadow for each box representing a node
@options[:shadow_box_offset]||= 3 # offset of the shadow for each box representing a node
@options[:line_color] ||= "#0e509e" # for connectors between boxes representing nodes
@options[:text_color] ||= "#000" # color of text in each box representing a node
@options[:font_size] ||= 10
@options[:font_weight] ||= "normal"
@options[:font_family] ||= "Arial"
@options[:max_text_length] ||= 35 # truncate any text in the box after the specified characters (appending '...')
end

end



The simplest way to use this is in the view of a Rails application like so:



<%= TreeDraw.new([@organisation], url_for(:controller=>"organisation", :action=>"details", :id=>nil)).draw("org_chart_drawing") %>




Where @organisation is the root of a tree (using acts_as_tree), the specified URL provides the link to the details for a node and the string specified in the draw method tells TreeDraw to place the Raphael canvas in the element with ID org_chart_drawing.

When drawing the box for a node, I am using the domTT tooltip to display a description of the detail for a node...

Have fun!

1 comment:

Anonymous said...

hello, i'm not sure what you really meant to do in the function below?


def generate_code(dom_element_id)
result = ""
result += ""
end