ruby-treemap 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/COPYING +340 -0
- data/ChangeLog +7 -0
- data/EXAMPLES +48 -0
- data/README +48 -0
- data/Rakefile +71 -0
- data/TODO +6 -0
- data/lib/treemap.rb +66 -0
- data/lib/treemap/color_base.rb +61 -0
- data/lib/treemap/gradient_color.rb +71 -0
- data/lib/treemap/html_output.rb +162 -0
- data/lib/treemap/image_output.rb +77 -0
- data/lib/treemap/layout_base.rb +30 -0
- data/lib/treemap/node.rb +134 -0
- data/lib/treemap/output_base.rb +39 -0
- data/lib/treemap/rectangle.rb +38 -0
- data/lib/treemap/slice_layout.rb +97 -0
- data/lib/treemap/squarified_layout.rb +141 -0
- data/lib/treemap/svg_output.rb +96 -0
- data/test/tc_color.rb +17 -0
- data/test/tc_html.rb +22 -0
- data/test/tc_simple.rb +30 -0
- data/test/tc_slice.rb +25 -0
- data/test/tc_squarified.rb +25 -0
- data/test/tc_svg.rb +21 -0
- data/test/test_base.rb +39 -0
- metadata +73 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
#--
|
2
|
+
# layout_base.rb - RubyTreemap
|
3
|
+
#
|
4
|
+
# Copyright (c) 2006 by Andrew Bruno <aeb@qnot.org>
|
5
|
+
#
|
6
|
+
# This program is free software; you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
8
|
+
# the Free Software Foundation; either version 2 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
#
|
11
|
+
#++
|
12
|
+
|
13
|
+
module Treemap
|
14
|
+
class LayoutBase
|
15
|
+
attr_accessor :position, :color
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
# Similar to the css style position. If set to :fixed x,y bounds calculations
|
19
|
+
# should be computed relative to the root bounds. If set to :absolute then they
|
20
|
+
# should be computed relative to the parent bounds.
|
21
|
+
# See http://www.w3.org/TR/CSS2/visuren.html#positioning-scheme
|
22
|
+
@position = :fixed
|
23
|
+
yield self if block_given?
|
24
|
+
end
|
25
|
+
|
26
|
+
# Subclasses will override
|
27
|
+
def process(node, bounds)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/treemap/node.rb
ADDED
@@ -0,0 +1,134 @@
|
|
1
|
+
#
|
2
|
+
# node.rb - RubyTreemap
|
3
|
+
#
|
4
|
+
# Copyright (c) 2006 by Andrew Bruno <aeb@qnot.org>
|
5
|
+
#
|
6
|
+
# This program is free software; you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
8
|
+
# the Free Software Foundation; either version 2 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
#
|
11
|
+
#
|
12
|
+
|
13
|
+
require "md5"
|
14
|
+
|
15
|
+
module Treemap
|
16
|
+
#
|
17
|
+
# A generic tree node class which is used to represent the data to be
|
18
|
+
# treemap'ed. The Layout and Output classes expect an object of this
|
19
|
+
# type to perform the treemap calculations on.
|
20
|
+
#
|
21
|
+
# Create a simple tree:
|
22
|
+
# root = Treemap::Node.new
|
23
|
+
# root.new_child(:size => 6)
|
24
|
+
# root.new_child(:size => 6)
|
25
|
+
# root.new_child(:size => 4)
|
26
|
+
# root.new_child(:size => 3)
|
27
|
+
# root.new_child(:size => 2)
|
28
|
+
# root.new_child(:size => 2)
|
29
|
+
# root.new_child(:size => 1)
|
30
|
+
#
|
31
|
+
# Initialize values:
|
32
|
+
# root = Treemap::Node.new(:label => "All", :size => 100, :color => "#FFCCFF")
|
33
|
+
# child1 = Treemap::Node.new(:label => "Child 1", :size => 50)
|
34
|
+
# child2 = Treemap::Node.new(:label => "Child 2", :size => 50)
|
35
|
+
# root.add_child(child1)
|
36
|
+
# root.add_child(child2)
|
37
|
+
#
|
38
|
+
#
|
39
|
+
class Treemap::Node
|
40
|
+
attr_accessor :id, :label, :color, :size, :bounds, :parent
|
41
|
+
attr_reader :children
|
42
|
+
|
43
|
+
#
|
44
|
+
# Create a new Node. You can initialize the node by passing in
|
45
|
+
# a hash with any of the following keys:
|
46
|
+
#
|
47
|
+
# * :size - The size that this node represents. For non-leaf nodes the
|
48
|
+
# size must be equal to the sum of the sizes of it's children. If size
|
49
|
+
# is nil then the value will be calculated by recursing the children.
|
50
|
+
# * :label - The label for this node. Used when displaying. Defaults to "node"
|
51
|
+
# * :color - The background fill color in hex to render when drawing the
|
52
|
+
# square. If the value is a number a color will be calculated. An example
|
53
|
+
# string color would be: ##FFFFFF (white)
|
54
|
+
# * :id - a unique id to assign to this node. Default id will be generated if
|
55
|
+
# one is not provided.
|
56
|
+
#
|
57
|
+
#
|
58
|
+
def initialize(opts = {})
|
59
|
+
@size = opts[:size]
|
60
|
+
@label = opts[:label]
|
61
|
+
@color = opts[:color]
|
62
|
+
@id = opts[:id]
|
63
|
+
@children = []
|
64
|
+
|
65
|
+
if(@id.nil?)
|
66
|
+
make_id
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
# Returns the depth of the node. 0 for root.
|
72
|
+
#
|
73
|
+
def depth
|
74
|
+
return 0 if parent.nil?
|
75
|
+
1 + self.parent.depth
|
76
|
+
end
|
77
|
+
|
78
|
+
def add_child(node)
|
79
|
+
# XXX check to see that a node with the same label doesn't already exist.
|
80
|
+
# having 2 nodes with the same label at the same depth
|
81
|
+
# doesn't seem to make sense
|
82
|
+
node.parent = self
|
83
|
+
@children.push(node)
|
84
|
+
end
|
85
|
+
|
86
|
+
#
|
87
|
+
# Creates a new node and adds it as a child. See new method.
|
88
|
+
#
|
89
|
+
def new_child(*args)
|
90
|
+
node = Treemap::Node.new(*args)
|
91
|
+
self.add_child(node)
|
92
|
+
end
|
93
|
+
|
94
|
+
def find
|
95
|
+
@children.find { |c| yield(c) }
|
96
|
+
end
|
97
|
+
|
98
|
+
def to_s
|
99
|
+
str = "[:id => " + id + " :label => " + label
|
100
|
+
str += " :size => " + size.to_s + " :color => " + color.to_s
|
101
|
+
if(not(bounds.nil?))
|
102
|
+
str += " :bounds => " + bounds.to_s
|
103
|
+
end
|
104
|
+
str += "]"
|
105
|
+
str
|
106
|
+
end
|
107
|
+
|
108
|
+
def size
|
109
|
+
return @size if !@size.nil?
|
110
|
+
sum = 0
|
111
|
+
@children.each do |c|
|
112
|
+
sum += c.size
|
113
|
+
end
|
114
|
+
|
115
|
+
sum
|
116
|
+
end
|
117
|
+
|
118
|
+
def label
|
119
|
+
return @label if !@label.nil?
|
120
|
+
"node - " + size.to_s
|
121
|
+
end
|
122
|
+
|
123
|
+
def leaf?
|
124
|
+
return true if @children.nil?
|
125
|
+
!(@children.size > 0)
|
126
|
+
end
|
127
|
+
|
128
|
+
private
|
129
|
+
def make_id
|
130
|
+
#XXX prob should change this. Create a better way to generate unique id's
|
131
|
+
@id = MD5.new([self.label, rand(100000000)].join("-")).hexdigest
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
#
|
2
|
+
# output_base.rb - RubyTreemap
|
3
|
+
#
|
4
|
+
# Copyright (c) 2006 by Andrew Bruno <aeb@qnot.org>
|
5
|
+
#
|
6
|
+
# This program is free software; you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
8
|
+
# the Free Software Foundation; either version 2 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
#
|
11
|
+
#
|
12
|
+
|
13
|
+
require 'rexml/document'
|
14
|
+
|
15
|
+
module Treemap
|
16
|
+
class OutputBase
|
17
|
+
attr_accessor(:width, :height, :layout, :color, :margin_top, :margin_left)
|
18
|
+
|
19
|
+
def initialize
|
20
|
+
@width = 800
|
21
|
+
@height = 600
|
22
|
+
@margin_top = 0
|
23
|
+
@margin_left = 0
|
24
|
+
@layout = Treemap::SquarifiedLayout.new
|
25
|
+
@color = Treemap::GradientColor.new
|
26
|
+
yield self if block_given?
|
27
|
+
end
|
28
|
+
|
29
|
+
protected
|
30
|
+
def bounds
|
31
|
+
x1 = self.margin_left
|
32
|
+
y1 = self.margin_top
|
33
|
+
x2 = self.width + self.margin_left
|
34
|
+
y2 = self.height + self.margin_top
|
35
|
+
bounds = Treemap::Rectangle.new(x1, y1, x2, y2)
|
36
|
+
return bounds
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
#
|
2
|
+
# rectangle.rb - RubyTreemap
|
3
|
+
#
|
4
|
+
# Copyright (c) 2006 by Andrew Bruno <aeb@qnot.org>
|
5
|
+
#
|
6
|
+
# This program is free software; you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
8
|
+
# the Free Software Foundation; either version 2 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
#
|
11
|
+
#
|
12
|
+
|
13
|
+
module Treemap
|
14
|
+
class Rectangle
|
15
|
+
attr_accessor :x1, :y1, :x2, :y2
|
16
|
+
|
17
|
+
def initialize(x1, y1, x2, y2)
|
18
|
+
@x1 = x1
|
19
|
+
@y1 = y1
|
20
|
+
@x2 = x2
|
21
|
+
@y2 = y2
|
22
|
+
|
23
|
+
yield self if block_given?
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_s
|
27
|
+
"[" + [@x1, @y1, @x2, @y2].join(",") + "]"
|
28
|
+
end
|
29
|
+
|
30
|
+
def width
|
31
|
+
@x2 - @x1
|
32
|
+
end
|
33
|
+
|
34
|
+
def height
|
35
|
+
@y2 - @y1
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
#
|
2
|
+
# slice_layout.rb - RubyTreemap
|
3
|
+
#
|
4
|
+
# Copyright (c) 2006 by Andrew Bruno <aeb@qnot.org>
|
5
|
+
#
|
6
|
+
# This program is free software; you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
8
|
+
# the Free Software Foundation; either version 2 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
#
|
11
|
+
#
|
12
|
+
|
13
|
+
require File.dirname(__FILE__) + "/layout_base"
|
14
|
+
|
15
|
+
class Treemap::SliceLayout < Treemap::LayoutBase
|
16
|
+
Vertical = 1
|
17
|
+
Horizontal = 2
|
18
|
+
|
19
|
+
def process(node, bounds, axis=nil)
|
20
|
+
bounds = bounds.clone
|
21
|
+
|
22
|
+
node.bounds = bounds.clone
|
23
|
+
|
24
|
+
if(@position == :absolute)
|
25
|
+
bounds.x2 = bounds.width
|
26
|
+
bounds.y2 = bounds.height
|
27
|
+
bounds.x1 = 0
|
28
|
+
bounds.y1 = 0
|
29
|
+
end
|
30
|
+
|
31
|
+
if(!node.leaf?)
|
32
|
+
process_children(node.children, bounds, axis)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def sum(children)
|
37
|
+
sum = 0
|
38
|
+
children.each do |c|
|
39
|
+
sum += c.size
|
40
|
+
end
|
41
|
+
sum
|
42
|
+
end
|
43
|
+
|
44
|
+
def process_children(children, bounds, axis=nil)
|
45
|
+
parent_bounds = bounds.clone
|
46
|
+
bounds = bounds.clone
|
47
|
+
|
48
|
+
axis = axis(bounds) if(axis.nil?)
|
49
|
+
|
50
|
+
width = axis == Vertical ? bounds.width : bounds.height
|
51
|
+
|
52
|
+
sum = sum(children)
|
53
|
+
|
54
|
+
# XXX should we sort? seems to produce better map but not tested on
|
55
|
+
# larger data sets
|
56
|
+
# children.sort {|c1,c2| c2.size <=> c1.size}.each do |c|
|
57
|
+
children.each do |c|
|
58
|
+
size = ((c.size.to_f / sum.to_f)*width).round
|
59
|
+
|
60
|
+
if(axis == Vertical)
|
61
|
+
bounds.x2 = bounds.x1 + size
|
62
|
+
else
|
63
|
+
bounds.y2 = bounds.y1 + size
|
64
|
+
end
|
65
|
+
|
66
|
+
process(c, bounds, flip(axis))
|
67
|
+
|
68
|
+
axis == Vertical ? bounds.x1 = bounds.x2 : bounds.y1 = bounds.y2
|
69
|
+
end
|
70
|
+
|
71
|
+
last = children.last
|
72
|
+
if(axis == Vertical)
|
73
|
+
last.bounds.x2 = parent_bounds.x2
|
74
|
+
else
|
75
|
+
last.bounds.y2 = parent_bounds.y2
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
def flip(axis)
|
81
|
+
return Horizontal if axis == Vertical
|
82
|
+
Vertical
|
83
|
+
end
|
84
|
+
|
85
|
+
def vertical?(bounds)
|
86
|
+
bounds.width > bounds.height
|
87
|
+
end
|
88
|
+
|
89
|
+
def horizontal?(bounds)
|
90
|
+
bounds.width < bounds.height
|
91
|
+
end
|
92
|
+
|
93
|
+
def axis(bounds)
|
94
|
+
return Horizontal if horizontal?(bounds)
|
95
|
+
Vertical
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
#
|
2
|
+
# squarified_layout.rb - RubyTreemap
|
3
|
+
#
|
4
|
+
# Copyright (c) 2006 by Andrew Bruno <aeb@qnot.org>
|
5
|
+
#
|
6
|
+
# This program is free software; you can redistribute it and/or modify
|
7
|
+
# it under the terms of the GNU General Public License as published by
|
8
|
+
# the Free Software Foundation; either version 2 of the License, or
|
9
|
+
# (at your option) any later version.
|
10
|
+
#
|
11
|
+
#
|
12
|
+
|
13
|
+
require File.dirname(__FILE__) + "/layout_base"
|
14
|
+
|
15
|
+
class Treemap::SquarifiedLayout < Treemap::SliceLayout
|
16
|
+
|
17
|
+
def process(node, bounds, axis=nil)
|
18
|
+
|
19
|
+
bounds = bounds.clone
|
20
|
+
|
21
|
+
node.bounds = bounds.clone
|
22
|
+
|
23
|
+
if(@position == :absolute)
|
24
|
+
bounds.x2 = bounds.width
|
25
|
+
bounds.y2 = bounds.height
|
26
|
+
bounds.x1 = 0
|
27
|
+
bounds.y1 = 0
|
28
|
+
end
|
29
|
+
|
30
|
+
if(!node.leaf?)
|
31
|
+
squarify_children(node, bounds, flip(axis))
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def squarify_children(node, bounds, axis)
|
36
|
+
|
37
|
+
parent_bounds = bounds.clone
|
38
|
+
bounds = bounds.clone
|
39
|
+
|
40
|
+
node.children.sort! {|a,b| b.size <=> a.size}
|
41
|
+
|
42
|
+
if(node.children.size < 2)
|
43
|
+
process_children(node.children, bounds, flip(axis))
|
44
|
+
end
|
45
|
+
|
46
|
+
parent_size = node.size
|
47
|
+
first_child = node.children.first
|
48
|
+
|
49
|
+
row_size = first_child.size
|
50
|
+
row_max = row_size.to_f / parent_size.to_f
|
51
|
+
total = row_max
|
52
|
+
|
53
|
+
prev_aspect = aspect_ratio(bounds, first_child.size.to_f / row_size.to_f, total, axis)
|
54
|
+
row = [first_child]
|
55
|
+
|
56
|
+
node.children[1 .. node.children.size-1].each do |c|
|
57
|
+
child_prop = c.size.to_f / parent_size.to_f
|
58
|
+
aspect = aspect_ratio(bounds, c.size.to_f / row_size.to_f, total + child_prop, axis)
|
59
|
+
|
60
|
+
if(aspect > prev_aspect)
|
61
|
+
newb = bounds.clone
|
62
|
+
if(axis == Vertical)
|
63
|
+
newb.x2 = bounds.x1 + ((bounds.width * total)).round
|
64
|
+
else
|
65
|
+
newb.y2 = bounds.y1 + ((bounds.height * total)).round
|
66
|
+
end
|
67
|
+
|
68
|
+
process_children(row, newb, flip(axis))
|
69
|
+
|
70
|
+
if(axis == Vertical)
|
71
|
+
bounds.x1 = newb.x2
|
72
|
+
else
|
73
|
+
bounds.y1 = newb.y2
|
74
|
+
end
|
75
|
+
|
76
|
+
axis = flip(axis)
|
77
|
+
parent_size -= row_size
|
78
|
+
row_size = c.size
|
79
|
+
total = row_max = row_size.to_f / parent_size.to_f
|
80
|
+
prev_aspect = aspect_ratio(bounds, c.size.to_f / row_size.to_f, total, axis)
|
81
|
+
row = [c]
|
82
|
+
else
|
83
|
+
row_size += c.size
|
84
|
+
total += child_prop
|
85
|
+
prev_aspect = aspect
|
86
|
+
row.push(c)
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
process_children(row, bounds, flip(axis))
|
91
|
+
end
|
92
|
+
|
93
|
+
def aspect_ratio(bounds, node_prop, row_prop, axis)
|
94
|
+
height = bounds.height * row_prop
|
95
|
+
width = bounds.width * node_prop
|
96
|
+
if(axis == Vertical)
|
97
|
+
width = bounds.width * row_prop
|
98
|
+
height = bounds.height * node_prop
|
99
|
+
end
|
100
|
+
|
101
|
+
return 0 if width == 0 and height == 0
|
102
|
+
|
103
|
+
a = 0;
|
104
|
+
b = 0;
|
105
|
+
if(width > 0)
|
106
|
+
a = height.to_f / width.to_f
|
107
|
+
end
|
108
|
+
if(height > 0)
|
109
|
+
b = width.to_f / height.to_f
|
110
|
+
end
|
111
|
+
|
112
|
+
ratio = [a, b].max
|
113
|
+
|
114
|
+
ratio
|
115
|
+
end
|
116
|
+
|
117
|
+
def axis(bounds)
|
118
|
+
# XXX experiment with switching
|
119
|
+
# axis = super(bounds)
|
120
|
+
# flip(axis)
|
121
|
+
end
|
122
|
+
|
123
|
+
# XXX another way of computing the aspect ratio
|
124
|
+
def aspect_ratio_method2(bounds, max, proportion, axis)
|
125
|
+
|
126
|
+
large = bounds.height
|
127
|
+
small = bounds.width
|
128
|
+
if(axis == Vertical)
|
129
|
+
large = bounds.width
|
130
|
+
small = bounds.height
|
131
|
+
end
|
132
|
+
|
133
|
+
ratio = (large * proportion).to_f / ((small * max).to_f / proportion.to_f).to_f
|
134
|
+
|
135
|
+
if(ratio < 1)
|
136
|
+
ratio = 1.to_f / ratio.to_f
|
137
|
+
end
|
138
|
+
|
139
|
+
ratio
|
140
|
+
end
|
141
|
+
end
|