woodhull-gruff 0.3.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (80) hide show
  1. data/History.txt +111 -0
  2. data/MIT-LICENSE +21 -0
  3. data/Manifest.txt +79 -0
  4. data/README.txt +40 -0
  5. data/Rakefile +55 -0
  6. data/assets/bubble.png +0 -0
  7. data/assets/city_scene/background/0000.png +0 -0
  8. data/assets/city_scene/background/0600.png +0 -0
  9. data/assets/city_scene/background/2000.png +0 -0
  10. data/assets/city_scene/clouds/cloudy.png +0 -0
  11. data/assets/city_scene/clouds/partly_cloudy.png +0 -0
  12. data/assets/city_scene/clouds/stormy.png +0 -0
  13. data/assets/city_scene/grass/default.png +0 -0
  14. data/assets/city_scene/haze/true.png +0 -0
  15. data/assets/city_scene/number_sample/1.png +0 -0
  16. data/assets/city_scene/number_sample/2.png +0 -0
  17. data/assets/city_scene/number_sample/default.png +0 -0
  18. data/assets/city_scene/sky/0000.png +0 -0
  19. data/assets/city_scene/sky/0200.png +0 -0
  20. data/assets/city_scene/sky/0400.png +0 -0
  21. data/assets/city_scene/sky/0600.png +0 -0
  22. data/assets/city_scene/sky/0800.png +0 -0
  23. data/assets/city_scene/sky/1000.png +0 -0
  24. data/assets/city_scene/sky/1200.png +0 -0
  25. data/assets/city_scene/sky/1400.png +0 -0
  26. data/assets/city_scene/sky/1500.png +0 -0
  27. data/assets/city_scene/sky/1700.png +0 -0
  28. data/assets/city_scene/sky/2000.png +0 -0
  29. data/assets/pc306715.jpg +0 -0
  30. data/assets/plastik/blue.png +0 -0
  31. data/assets/plastik/green.png +0 -0
  32. data/assets/plastik/red.png +0 -0
  33. data/init.rb +2 -0
  34. data/lib/gruff.rb +27 -0
  35. data/lib/gruff/accumulator_bar.rb +27 -0
  36. data/lib/gruff/area.rb +58 -0
  37. data/lib/gruff/bar.rb +84 -0
  38. data/lib/gruff/bar_conversion.rb +46 -0
  39. data/lib/gruff/base.rb +1116 -0
  40. data/lib/gruff/bullet.rb +109 -0
  41. data/lib/gruff/deprecated.rb +39 -0
  42. data/lib/gruff/line.rb +105 -0
  43. data/lib/gruff/mini/bar.rb +32 -0
  44. data/lib/gruff/mini/legend.rb +77 -0
  45. data/lib/gruff/mini/pie.rb +36 -0
  46. data/lib/gruff/mini/side_bar.rb +35 -0
  47. data/lib/gruff/net.rb +142 -0
  48. data/lib/gruff/photo_bar.rb +100 -0
  49. data/lib/gruff/pie.rb +124 -0
  50. data/lib/gruff/scene.rb +209 -0
  51. data/lib/gruff/side_bar.rb +115 -0
  52. data/lib/gruff/side_stacked_bar.rb +74 -0
  53. data/lib/gruff/spider.rb +130 -0
  54. data/lib/gruff/stacked_area.rb +67 -0
  55. data/lib/gruff/stacked_bar.rb +54 -0
  56. data/lib/gruff/stacked_mixin.rb +23 -0
  57. data/rails_generators/gruff/gruff_generator.rb +63 -0
  58. data/rails_generators/gruff/templates/controller.rb +32 -0
  59. data/rails_generators/gruff/templates/functional_test.rb +24 -0
  60. data/test/gruff_test_case.rb +123 -0
  61. data/test/test_accumulator_bar.rb +50 -0
  62. data/test/test_area.rb +134 -0
  63. data/test/test_bar.rb +283 -0
  64. data/test/test_base.rb +8 -0
  65. data/test/test_bullet.rb +26 -0
  66. data/test/test_legend.rb +68 -0
  67. data/test/test_line.rb +513 -0
  68. data/test/test_mini_bar.rb +32 -0
  69. data/test/test_mini_pie.rb +20 -0
  70. data/test/test_mini_side_bar.rb +37 -0
  71. data/test/test_net.rb +230 -0
  72. data/test/test_photo.rb +41 -0
  73. data/test/test_pie.rb +154 -0
  74. data/test/test_scene.rb +100 -0
  75. data/test/test_side_bar.rb +12 -0
  76. data/test/test_sidestacked_bar.rb +89 -0
  77. data/test/test_spider.rb +216 -0
  78. data/test/test_stacked_area.rb +52 -0
  79. data/test/test_stacked_bar.rb +52 -0
  80. metadata +160 -0
@@ -0,0 +1,100 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+
3
+ # EXPERIMENTAL!
4
+ #
5
+ # Doesn't work yet.
6
+ #
7
+ class Gruff::PhotoBar < Gruff::Base
8
+
9
+ # TODO
10
+ #
11
+ # define base and cap in yml
12
+ # allow for image directory to be located elsewhere
13
+ # more exact measurements for bar heights (go all the way to the bottom of the graph)
14
+ # option to tile images instead of use a single image
15
+ # drop base label a few px lower so photo bar graphs can have a base dropping over the lower marker line
16
+ #
17
+
18
+ # The name of a pre-packaged photo-based theme.
19
+ attr_reader :theme
20
+
21
+ # def initialize(target_width=800)
22
+ # super
23
+ # init_photo_bar_graphics()
24
+ # end
25
+
26
+ def draw
27
+ super
28
+ return unless @has_data
29
+
30
+ return # TODO Remove for further development
31
+
32
+ init_photo_bar_graphics()
33
+
34
+ #Draw#define_clip_path()
35
+ #Draw#clip_path(pathname)
36
+ #Draw#composite....with bar graph image OverCompositeOp
37
+ #
38
+ # See also
39
+ #
40
+ # Draw.pattern # define an image to tile as the filling of a draw object
41
+ #
42
+
43
+ # Setup spacing.
44
+ #
45
+ # Columns sit side-by-side.
46
+ spacing_factor = 0.9
47
+ @bar_width = @norm_data[0][DATA_COLOR_INDEX].columns
48
+
49
+ @norm_data.each_with_index do |data_row, row_index|
50
+
51
+ data_row[DATA_VALUES_INDEX].each_with_index do |data_point, point_index|
52
+ data_point = 0 if data_point.nil?
53
+ # Use incremented x and scaled y
54
+ left_x = @graph_left + (@bar_width * (row_index + point_index + ((@data.length - 1) * point_index)))
55
+ left_y = @graph_top + (@graph_height - data_point * @graph_height) + 1
56
+ right_x = left_x + @bar_width * spacing_factor
57
+ right_y = @graph_top + @graph_height - 1
58
+
59
+ bar_image_width = data_row[DATA_COLOR_INDEX].columns
60
+ bar_image_height = right_y.to_f - left_y.to_f
61
+
62
+ # Crop to scale for data
63
+ bar_image = data_row[DATA_COLOR_INDEX].crop(0, 0, bar_image_width, bar_image_height)
64
+
65
+ @d.gravity = NorthWestGravity
66
+ @d = @d.composite(left_x, left_y, bar_image_width, bar_image_height, bar_image)
67
+
68
+ # Calculate center based on bar_width and current row
69
+ label_center = @graph_left + (@data.length * @bar_width * point_index) + (@data.length * @bar_width / 2.0)
70
+ draw_label(label_center, point_index)
71
+ end
72
+
73
+ end
74
+
75
+ @d.draw(@base_image)
76
+ end
77
+
78
+
79
+ # Return the chosen theme or the default
80
+ def theme
81
+ @theme || 'plastik'
82
+ end
83
+
84
+ protected
85
+
86
+ # Sets up colors with a list of images that will be used.
87
+ # Images should be 340px tall
88
+ def init_photo_bar_graphics
89
+ color_list = Array.new
90
+ theme_dir = File.dirname(__FILE__) + '/../../assets/' + theme
91
+
92
+ Dir.open(theme_dir).each do |file|
93
+ next unless /\.png$/.match(file)
94
+ color_list << Image.read("#{theme_dir}/#{file}").first
95
+ end
96
+ @colors = color_list
97
+ end
98
+
99
+ end
100
+
@@ -0,0 +1,124 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+
3
+ ##
4
+ # Here's how to make a Pie graph:
5
+ #
6
+ # g = Gruff::Pie.new
7
+ # g.title = "Visual Pie Graph Test"
8
+ # g.data 'Fries', 20
9
+ # g.data 'Hamburgers', 50
10
+ # g.write("test/output/pie_keynote.png")
11
+ #
12
+ # To control where the pie chart starts creating slices, use #zero_degree.
13
+
14
+ class Gruff::Pie < Gruff::Base
15
+
16
+ TEXT_OFFSET_PERCENTAGE = 0.15
17
+
18
+ # Can be used to make the pie start cutting slices at the top (-90.0)
19
+ # or at another angle. Default is 0.0, which starts at 3 o'clock.
20
+ attr_accessor :zero_degree
21
+
22
+ def initialize_ivars
23
+ super
24
+ @zero_degree = 0.0
25
+ end
26
+
27
+ def draw
28
+ @hide_line_markers = true
29
+
30
+ super
31
+
32
+ return unless @has_data
33
+
34
+ diameter = @graph_height
35
+ radius = ([@graph_width, @graph_height].min / 2.0) * 0.8
36
+ top_x = @graph_left + (@graph_width - diameter) / 2.0
37
+ center_x = @graph_left + (@graph_width / 2.0)
38
+ center_y = @graph_top + (@graph_height / 2.0) - 10 # Move graph up a bit
39
+ total_sum = sums_for_pie()
40
+ prev_degrees = @zero_degree
41
+
42
+ # Use full data since we can easily calculate percentages
43
+ data = (@sort ? @data.sort{ |a, b| a[DATA_VALUES_INDEX].first <=> b[DATA_VALUES_INDEX].first } : @data)
44
+ data.each do |data_row|
45
+ if data_row[DATA_VALUES_INDEX].first > 0
46
+ @d = @d.stroke data_row[DATA_COLOR_INDEX]
47
+ @d = @d.fill 'transparent'
48
+ @d.stroke_width(radius) # stroke width should be equal to radius. we'll draw centered on (radius / 2)
49
+
50
+ current_degrees = (data_row[DATA_VALUES_INDEX].first / total_sum) * 360.0
51
+
52
+ # ellipse will draw the the stroke centered on the first two parameters offset by the second two.
53
+ # therefore, in order to draw a circle of the proper diameter we must center the stroke at
54
+ # half the radius for both x and y
55
+ @d = @d.ellipse(center_x, center_y,
56
+ radius / 2.0, radius / 2.0,
57
+ prev_degrees, prev_degrees + current_degrees + 0.5) # <= +0.5 'fudge factor' gets rid of the ugly gaps
58
+
59
+ half_angle = prev_degrees + ((prev_degrees + current_degrees) - prev_degrees) / 2
60
+
61
+ # Following line is commented to allow display of the percentiles
62
+ # bug appeared between r90 and r92
63
+ # unless @hide_line_markers then
64
+ # End the string with %% to escape the single %.
65
+ # RMagick must use sprintf with the string and % has special significance.
66
+ label_string = ((data_row[DATA_VALUES_INDEX].first / total_sum) *
67
+ 100.0).round.to_s + '%%'
68
+ @d = draw_label(center_x,center_y, half_angle,
69
+ radius + (radius * TEXT_OFFSET_PERCENTAGE),
70
+ label_string)
71
+ # end
72
+
73
+ prev_degrees += current_degrees
74
+ end
75
+ end
76
+
77
+ # TODO debug a circle where the text is drawn...
78
+
79
+ @d.draw(@base_image)
80
+ end
81
+
82
+ private
83
+
84
+ ##
85
+ # Labels are drawn around a slightly wider ellipse to give room for
86
+ # labels on the left and right.
87
+ def draw_label(center_x, center_y, angle, radius, amount)
88
+ # TODO Don't use so many hard-coded numbers
89
+ r_offset = 20.0 # The distance out from the center of the pie to get point
90
+ x_offset = center_x # + 15.0 # The label points need to be tweaked slightly
91
+ y_offset = center_y # This one doesn't though
92
+ radius_offset = (radius + r_offset)
93
+ ellipse_factor = radius_offset * 0.15
94
+ x = x_offset + ((radius_offset + ellipse_factor) * Math.cos(angle.deg2rad))
95
+ y = y_offset + (radius_offset * Math.sin(angle.deg2rad))
96
+
97
+ # Draw label
98
+ @d.fill = @font_color
99
+ @d.font = @font if @font
100
+ @d.pointsize = scale_fontsize(@marker_font_size)
101
+ @d.stroke = 'transparent'
102
+ @d.font_weight = BoldWeight
103
+ @d.gravity = CenterGravity
104
+ @d.annotate_scaled( @base_image,
105
+ 0, 0,
106
+ x, y,
107
+ amount, @scale)
108
+ end
109
+
110
+ def sums_for_pie
111
+ total_sum = 0.0
112
+ @data.collect {|data_row| total_sum += data_row[DATA_VALUES_INDEX].first }
113
+ total_sum
114
+ end
115
+
116
+ end
117
+
118
+ class Float
119
+ # Used for degree => radian conversions
120
+ def deg2rad
121
+ self * (Math::PI/180.0)
122
+ end
123
+ end
124
+
@@ -0,0 +1,209 @@
1
+
2
+ require "observer"
3
+ require File.dirname(__FILE__) + '/base'
4
+
5
+ ##
6
+ # A scene is a non-linear graph that assembles layers together to tell a story.
7
+ # Layers are folders with appropriately named files (see below). You can group
8
+ # layers and control them together or just set their values individually.
9
+ #
10
+ # Examples:
11
+ #
12
+ # * A city scene that changes with the time of day and the weather conditions.
13
+ # * A traffic map that shows red lines on streets that are crowded and green on free-flowing ones.
14
+ #
15
+ # Usage:
16
+ #
17
+ # g = Gruff::Scene.new("500x100", "path/to/city_scene_directory")
18
+ #
19
+ # # Define order of layers, back to front
20
+ # g.layers = %w(background haze sky clouds)
21
+ #
22
+ # # Define groups that will be controlled by the same input
23
+ # g.weather_group = %w(clouds)
24
+ # g.time_group = %w(background sky)
25
+ #
26
+ # # Set values for the layers or groups
27
+ # g.weather = "cloudy"
28
+ # g.time = Time.now
29
+ # g.haze = true
30
+ #
31
+ # # Write the final graph to disk
32
+ # g.write "hazy_daytime_city_scene.png"
33
+ #
34
+ #
35
+ # There are several rules that will magically select a layer when possible.
36
+ #
37
+ # * Numbered files will be selected according to the closest value that is less than the input value.
38
+ # * 'true.png' and 'false.png' will be used as booleans.
39
+ # * Other named files will be used if the input matches the filename (without the filetype extension).
40
+ # * If there is a file named 'default.png', it will be used unless other input values are set for the corresponding layer.
41
+
42
+ class Gruff::Scene < Gruff::Base
43
+
44
+ # An array listing the foldernames that will be rendered, from back to front.
45
+ #
46
+ # g.layers = %w(sky clouds buildings street people)
47
+ #
48
+ attr_reader :layers
49
+
50
+ def initialize(target_width, base_dir)
51
+ @base_dir = base_dir
52
+ @groups = {}
53
+ @layers = []
54
+ super target_width
55
+ end
56
+
57
+ def draw
58
+ # Join all the custom paths and filter out the empty ones
59
+ image_paths = @layers.map { |layer| layer.path }.select { |path| !path.empty? }
60
+ images = Magick::ImageList.new(*image_paths)
61
+ @base_image = images.flatten_images
62
+ end
63
+
64
+ def layers=(ordered_list)
65
+ ordered_list.each do |layer_name|
66
+ @layers << Gruff::Layer.new(@base_dir, layer_name)
67
+ end
68
+ end
69
+
70
+ # Group layers to input values
71
+ #
72
+ # g.weather_group = ["sky", "sea", "clouds"]
73
+ #
74
+ # Set input values
75
+ #
76
+ # g.weather = "cloudy"
77
+ #
78
+ def method_missing(method_name, *args)
79
+ case method_name.to_s
80
+ when /^(\w+)_group=$/
81
+ add_group $1, *args
82
+ return
83
+ when /^(\w+)=$/
84
+ set_input $1, args.first
85
+ return
86
+ end
87
+ super
88
+ end
89
+
90
+ private
91
+
92
+ def add_group(input_name, layer_names)
93
+ @groups[input_name] = Gruff::Group.new(input_name, @layers.select { |layer| layer_names.include?(layer.name) })
94
+ end
95
+
96
+ def set_input(input_name, input_value)
97
+ if not @groups[input_name].nil?
98
+ @groups[input_name].send_updates(input_value)
99
+ else
100
+ if chosen_layer = @layers.detect { |layer| layer.name == input_name }
101
+ chosen_layer.update input_value
102
+ end
103
+ end
104
+ end
105
+
106
+ end
107
+
108
+
109
+ class Gruff::Group
110
+
111
+ include Observable
112
+ attr_reader :name
113
+
114
+ def initialize(folder_name, layers)
115
+ @name = folder_name
116
+ layers.each do |layer|
117
+ layer.observe self
118
+ end
119
+ end
120
+
121
+ def send_updates(value)
122
+ changed
123
+ notify_observers value
124
+ end
125
+
126
+ end
127
+
128
+
129
+ class Gruff::Layer
130
+
131
+ attr_reader :name
132
+
133
+ def initialize(base_dir, folder_name)
134
+ @base_dir = base_dir.to_s
135
+ @name = folder_name.to_s
136
+ @filenames = Dir.open(File.join(base_dir, folder_name)).entries.select { |file| file =~ /^[^.]+\.png$/ }
137
+ @selected_filename = select_default
138
+ end
139
+
140
+ # Register this layer so it receives updates from the group
141
+ def observe(obj)
142
+ obj.add_observer self
143
+ end
144
+
145
+ # Choose the appropriate filename for this layer, based on the input
146
+ def update(value)
147
+ @selected_filename = case value.to_s
148
+ when /^(true|false)$/
149
+ select_boolean value
150
+ when /^(\w|\s)+$/
151
+ select_string value
152
+ when /^-?(\d+\.)?\d+$/
153
+ select_numeric value
154
+ when /(\d\d):(\d\d):\d\d/
155
+ select_time "#{$1}#{$2}"
156
+ else
157
+ select_default
158
+ end
159
+ # Finally, try to use 'default' if we're still blank
160
+ @selected_filename ||= select_default
161
+ end
162
+
163
+ # Returns the full path to the selected image, or a blank string
164
+ def path
165
+ unless @selected_filename.nil? || @selected_filename.empty?
166
+ return File.join(@base_dir, @name, @selected_filename)
167
+ end
168
+ ''
169
+ end
170
+
171
+ private
172
+
173
+ # Match "true.png" or "false.png"
174
+ def select_boolean(value)
175
+ file_exists_or_blank value.to_s
176
+ end
177
+
178
+ # Match -5 to _5.png
179
+ def select_numeric(value)
180
+ file_exists_or_blank value.to_s.gsub('-', '_')
181
+ end
182
+
183
+ def select_time(value)
184
+ times = @filenames.map { |filename| filename.gsub('.png', '') }
185
+ times.each_with_index do |time, index|
186
+ if (time > value) && (index > 0)
187
+ return "#{times[index - 1]}.png"
188
+ end
189
+ end
190
+ return "#{times.last}.png"
191
+ end
192
+
193
+ # Match "partly cloudy" to "partly_cloudy.png"
194
+ def select_string(value)
195
+ file_exists_or_blank value.to_s.gsub(' ', '_')
196
+ end
197
+
198
+ def select_default
199
+ @filenames.include?("default.png") ? "default.png" : ''
200
+ end
201
+
202
+ # Returns the string "#{filename}.png", if it exists.
203
+ #
204
+ # Failing that, it returns default.png, or '' if that doesn't exist.
205
+ def file_exists_or_blank(filename)
206
+ @filenames.include?("#{filename}.png") ? "#{filename}.png" : select_default
207
+ end
208
+
209
+ end
@@ -0,0 +1,115 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+
3
+ ##
4
+ # Graph with individual horizontal bars instead of vertical bars.
5
+
6
+ class Gruff::SideBar < Gruff::Base
7
+
8
+ def draw
9
+ @has_left_labels = true
10
+ super
11
+
12
+ return unless @has_data
13
+
14
+ # Setup spacing.
15
+ #
16
+ spacing_factor = 0.9
17
+
18
+ @bars_width = @graph_height / @column_count.to_f
19
+ @bar_width = @bars_width * spacing_factor / @norm_data.size
20
+ @d = @d.stroke_opacity 0.0
21
+ height = Array.new(@column_count, 0)
22
+ length = Array.new(@column_count, @graph_left)
23
+ padding = (@bars_width * (1 - spacing_factor)) / 2
24
+
25
+ @norm_data.each_with_index do |data_row, row_index|
26
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
27
+
28
+ data_row[DATA_VALUES_INDEX].each_with_index do |data_point, point_index|
29
+
30
+ # Using the original calcs from the stacked bar chart
31
+ # to get the difference between
32
+ # part of the bart chart we wish to stack.
33
+ temp1 = @graph_left + (@graph_width - data_point * @graph_width - height[point_index])
34
+ temp2 = @graph_left + @graph_width - height[point_index]
35
+ difference = temp2 - temp1
36
+
37
+ left_x = length[point_index] - 1
38
+ left_y = @graph_top + (@bars_width * point_index) + (@bar_width * row_index) + padding
39
+ right_x = left_x + difference
40
+ right_y = left_y + @bar_width
41
+
42
+ height[point_index] += (data_point * @graph_width)
43
+
44
+ @d = @d.rectangle(left_x, left_y, right_x, right_y)
45
+
46
+ # Calculate center based on bar_width and current row
47
+ label_center = @graph_top + (@bars_width * point_index + @bars_width / 2)
48
+ draw_label(label_center, point_index)
49
+ end
50
+
51
+ end
52
+
53
+ @d.draw(@base_image)
54
+ end
55
+
56
+ protected
57
+
58
+ # Instead of base class version, draws vertical background lines and label
59
+ def draw_line_markers
60
+
61
+ return if @hide_line_markers
62
+
63
+ @d = @d.stroke_antialias false
64
+
65
+ # Draw horizontal line markers and annotate with numbers
66
+ @d = @d.stroke(@marker_color)
67
+ @d = @d.stroke_width 1
68
+ number_of_lines = 5
69
+
70
+ # TODO Round maximum marker value to a round number like 100, 0.1, 0.5, etc.
71
+ increment = significant(@maximum_value.to_f / number_of_lines)
72
+ (0..number_of_lines).each do |index|
73
+
74
+ line_diff = (@graph_right - @graph_left) / number_of_lines
75
+ x = @graph_right - (line_diff * index) - 1
76
+ @d = @d.line(x, @graph_bottom, x, @graph_top)
77
+ diff = index - number_of_lines
78
+ marker_label = diff.abs * increment
79
+
80
+ unless @hide_line_numbers
81
+ @d.fill = @font_color
82
+ @d.font = @font if @font
83
+ @d.stroke = 'transparent'
84
+ @d.pointsize = scale_fontsize(@marker_font_size)
85
+ @d.gravity = CenterGravity
86
+ # TODO Center text over line
87
+ @d = @d.annotate_scaled( @base_image,
88
+ 0, 0, # Width of box to draw text in
89
+ x, @graph_bottom + (LABEL_MARGIN * 2.0), # Coordinates of text
90
+ marker_label.to_s, @scale)
91
+ end # unless
92
+ @d = @d.stroke_antialias true
93
+ end
94
+ end
95
+
96
+ ##
97
+ # Draw on the Y axis instead of the X
98
+
99
+ def draw_label(y_offset, index)
100
+ if !@labels[index].nil? && @labels_seen[index].nil?
101
+ @d.fill = @font_color
102
+ @d.font = @font if @font
103
+ @d.stroke = 'transparent'
104
+ @d.font_weight = NormalWeight
105
+ @d.pointsize = scale_fontsize(@marker_font_size)
106
+ @d.gravity = EastGravity
107
+ @d = @d.annotate_scaled(@base_image,
108
+ 1, 1,
109
+ -@graph_left + LABEL_MARGIN * 2.0, y_offset,
110
+ @labels[index], @scale)
111
+ @labels_seen[index] = 1
112
+ end
113
+ end
114
+
115
+ end