Showing posts with label tree drawing. Show all posts
Showing posts with label tree drawing. Show all posts

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!