gruffy 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 = Gruffy::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 Gruffy::Scene < Gruffy::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 << Gruffy::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] = Gruffy::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 Gruffy::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 Gruffy::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$/ }.sort
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,138 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+
3
+ ##
4
+ # Graph with individual horizontal bars instead of vertical bars.
5
+
6
+ class Gruffy::SideBar < Gruffy::Base
7
+
8
+ # Spacing factor applied between bars
9
+ attr_accessor :bar_spacing
10
+
11
+ def draw
12
+ @has_left_labels = true
13
+ super
14
+
15
+ return unless @has_data
16
+ draw_bars
17
+ end
18
+
19
+ protected
20
+
21
+ def draw_bars
22
+ # Setup spacing.
23
+ #
24
+ @bar_spacing ||= 0.9
25
+
26
+ @bars_width = @graph_height / @column_count.to_f
27
+ @bar_width = @bars_width / @norm_data.size
28
+ @d = @d.stroke_opacity 0.0
29
+ height = Array.new(@column_count, 0)
30
+ length = Array.new(@column_count, @graph_left)
31
+ padding = (@bar_width * (1 - @bar_spacing)) / 2
32
+
33
+ # if we're a side stacked bar then we don't need to draw ourself at all
34
+ # because sometimes (due to different heights/min/max) you can actually
35
+ # see both graphs and it looks like crap
36
+ return if self.is_a?(Gruffy::SideStackedBar)
37
+
38
+ @norm_data.each_with_index do |data_row, row_index|
39
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
40
+
41
+ data_row[DATA_VALUES_INDEX].each_with_index do |data_point, point_index|
42
+
43
+ # Using the original calcs from the stacked bar chart
44
+ # to get the difference between
45
+ # part of the bart chart we wish to stack.
46
+ temp1 = @graph_left + (@graph_width - data_point * @graph_width - height[point_index])
47
+ temp2 = @graph_left + @graph_width - height[point_index]
48
+ difference = temp2 - temp1
49
+
50
+ left_x = length[point_index] - 1
51
+ left_y = @graph_top + (@bars_width * point_index) + (@bar_width * row_index) + padding
52
+ right_x = left_x + difference
53
+ right_y = left_y + @bar_width * @bar_spacing
54
+
55
+ height[point_index] += (data_point * @graph_width)
56
+
57
+ @d = @d.rectangle(left_x, left_y, right_x, right_y)
58
+
59
+ # Calculate center based on bar_width and current row
60
+
61
+ if @use_data_label
62
+ label_center = @graph_top + (@bar_width * (row_index+point_index) + @bar_width / 2)
63
+ draw_label(label_center, row_index, @norm_data[row_index][DATA_LABEL_INDEX])
64
+ else
65
+ label_center = @graph_top + (@bars_width * point_index + @bars_width / 2)
66
+ draw_label(label_center, point_index)
67
+ end
68
+ if @show_labels_for_bar_values
69
+ val = (@label_formatting || '%.2f') % @norm_data[row_index][3][point_index]
70
+ draw_value_label(right_x+40, (@graph_top + (((row_index+point_index+1) * @bar_width) - (@bar_width / 2)))-12, val.commify, true)
71
+ end
72
+ end
73
+
74
+ end
75
+
76
+ @d.draw(@base_image)
77
+ end
78
+
79
+ # Instead of base class version, draws vertical background lines and label
80
+ def draw_line_markers
81
+
82
+ return if @hide_line_markers
83
+
84
+ @d = @d.stroke_antialias false
85
+
86
+ # Draw horizontal line markers and annotate with numbers
87
+ @d = @d.stroke(@marker_color)
88
+ @d = @d.stroke_width 1
89
+ number_of_lines = @marker_count || 5
90
+ number_of_lines = 1 if number_of_lines == 0
91
+
92
+ # TODO Round maximum marker value to a round number like 100, 0.1, 0.5, etc.
93
+ increment = significant(@spread.to_f / number_of_lines)
94
+ (0..number_of_lines).each do |index|
95
+
96
+ line_diff = (@graph_right - @graph_left) / number_of_lines
97
+ x = @graph_right - (line_diff * index) - 1
98
+ @d = @d.line(x, @graph_bottom, x, @graph_top)
99
+ diff = index - number_of_lines
100
+ marker_label = diff.abs * increment + @minimum_value
101
+
102
+ unless @hide_line_numbers
103
+ @d.fill = @font_color
104
+ @d.font = @font if @font
105
+ @d.stroke = 'transparent'
106
+ @d.pointsize = scale_fontsize(@marker_font_size)
107
+ @d.gravity = CenterGravity
108
+ # TODO Center text over line
109
+ @d = @d.annotate_scaled(@base_image,
110
+ 0, 0, # Width of box to draw text in
111
+ x, @graph_bottom + (LABEL_MARGIN * 2.0), # Coordinates of text
112
+ marker_label.to_s, @scale)
113
+ end # unless
114
+ @d = @d.stroke_antialias true
115
+ end
116
+ end
117
+
118
+ ##
119
+ # Draw on the Y axis instead of the X
120
+
121
+ def draw_label(y_offset, index, label=nil)
122
+ if !@labels[index].nil? && @labels_seen[index].nil?
123
+ lbl = (@use_data_label) ? label : @labels[index]
124
+ @d.fill = @font_color
125
+ @d.font = @font if @font
126
+ @d.stroke = 'transparent'
127
+ @d.font_weight = NormalWeight
128
+ @d.pointsize = scale_fontsize(@marker_font_size)
129
+ @d.gravity = EastGravity
130
+ @d = @d.annotate_scaled(@base_image,
131
+ 1, 1,
132
+ -@graph_left + LABEL_MARGIN * 2.0, y_offset,
133
+ lbl, @scale)
134
+ @labels_seen[index] = 1
135
+ end
136
+ end
137
+
138
+ end
@@ -0,0 +1,97 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+ require File.dirname(__FILE__) + '/side_bar'
3
+ require File.dirname(__FILE__) + '/stacked_mixin'
4
+
5
+ ##
6
+ # New gruffy graph type added to enable sideways stacking bar charts
7
+ # (basically looks like a x/y flip of a standard stacking bar chart)
8
+ #
9
+ # alun.eyre@googlemail.com
10
+
11
+ class Gruffy::SideStackedBar < Gruffy::SideBar
12
+ include StackedMixin
13
+
14
+ # Spacing factor applied between bars
15
+ attr_accessor :bar_spacing
16
+
17
+ def draw
18
+ @has_left_labels = true
19
+ get_maximum_by_stack
20
+ super
21
+ end
22
+
23
+ protected
24
+
25
+ def draw_bars
26
+ # Setup spacing.
27
+ #
28
+ # Columns sit stacked.
29
+ @bar_spacing ||= 0.9
30
+
31
+ @bar_width = @graph_height / @column_count.to_f
32
+ @d = @d.stroke_opacity 0.0
33
+ height = Array.new(@column_count, 0)
34
+ length = Array.new(@column_count, @graph_left)
35
+ padding = (@bar_width * (1 - @bar_spacing)) / 2
36
+ if @show_labels_for_bar_values
37
+ label_values = Array.new
38
+ 0.upto(@column_count-1) {|i| label_values[i] = {:value => 0, :right_x => 0}}
39
+ end
40
+ @norm_data.each_with_index do |data_row, row_index|
41
+ data_row[DATA_VALUES_INDEX].each_with_index do |data_point, point_index|
42
+
43
+ ## using the original calcs from the stacked bar chart to get the difference between
44
+ ## part of the bart chart we wish to stack.
45
+ temp1 = @graph_left + (@graph_width -
46
+ data_point * @graph_width -
47
+ height[point_index]) + 1
48
+ temp2 = @graph_left + @graph_width - height[point_index] - 1
49
+ difference = temp2 - temp1
50
+
51
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
52
+
53
+ left_x = length[point_index] #+ 1
54
+ left_y = @graph_top + (@bar_width * point_index) + padding
55
+ right_x = left_x + difference
56
+ right_y = left_y + @bar_width * @bar_spacing
57
+ length[point_index] += difference
58
+ height[point_index] += (data_point * @graph_width - 2)
59
+
60
+ if @show_labels_for_bar_values
61
+ label_values[point_index][:value] += @norm_data[row_index][3][point_index]
62
+ label_values[point_index][:right_x] = right_x
63
+ end
64
+
65
+ # if a data point is 0 it can result in weird really thing lines
66
+ # that shouldn't even be there being drawn on top of the existing
67
+ # bar - this is bad
68
+ if data_point != 0
69
+ @d = @d.rectangle(left_x, left_y, right_x, right_y)
70
+ # Calculate center based on bar_width and current row
71
+ end
72
+ # we still need to draw the labels
73
+ # Calculate center based on bar_width and current row
74
+ label_center = @graph_top + (@bar_width * point_index) + (@bar_width * @bar_spacing / 2.0)
75
+ draw_label(label_center, point_index)
76
+ end
77
+
78
+ end
79
+ if @show_labels_for_bar_values
80
+ label_values.each_with_index do |data, i|
81
+ val = (@label_formatting || "%.2f") % data[:value]
82
+ draw_value_label(data[:right_x]+40, (@graph_top + (((i+1) * @bar_width) - (@bar_width / 2)))-12, val.commify, true)
83
+ end
84
+ end
85
+
86
+ @d.draw(@base_image)
87
+ end
88
+
89
+ def larger_than_max?(data_point, index=0)
90
+ max(data_point, index) > @maximum_value
91
+ end
92
+
93
+ def max(data_point, index)
94
+ @data.inject(0) {|sum, item| sum + item[DATA_VALUES_INDEX][index]}
95
+ end
96
+
97
+ end
@@ -0,0 +1,125 @@
1
+
2
+ require File.dirname(__FILE__) + '/base'
3
+
4
+ # Experimental!!! See also the Net graph.
5
+ #
6
+ # Submitted by Kevin Clark http://glu.ttono.us/
7
+ class Gruffy::Spider < Gruffy::Base
8
+
9
+ # Hide all text
10
+ attr_reader :hide_text
11
+ attr_accessor :hide_axes
12
+ attr_reader :transparent_background
13
+ attr_accessor :rotation
14
+
15
+ def transparent_background=(value)
16
+ @transparent_background = value
17
+ @base_image = render_transparent_background if value
18
+ end
19
+
20
+ def hide_text=(value)
21
+ @hide_title = @hide_text = value
22
+ end
23
+
24
+ def initialize(max_value, target_width = 800)
25
+ super(target_width)
26
+ @max_value = max_value
27
+ @hide_legend = true
28
+ @rotation = 0
29
+ end
30
+
31
+ def draw
32
+ @hide_line_markers = true
33
+
34
+ super
35
+
36
+ return unless @has_data
37
+
38
+ # Setup basic positioning
39
+ radius = @graph_height / 2.0
40
+ center_x = @graph_left + (@graph_width / 2.0)
41
+ center_y = @graph_top + (@graph_height / 2.0) - 25 # Move graph up a bit
42
+
43
+ @unit_length = radius / @max_value
44
+
45
+ additive_angle = (2 * Math::PI)/ @data.size
46
+
47
+ # Draw axes
48
+ draw_axes(center_x, center_y, radius, additive_angle) unless hide_axes
49
+
50
+ # Draw polygon
51
+ draw_polygon(center_x, center_y, additive_angle)
52
+
53
+ @d.draw(@base_image)
54
+ end
55
+
56
+ private
57
+
58
+ def normalize_points(value)
59
+ value * @unit_length
60
+ end
61
+
62
+ def draw_label(center_x, center_y, angle, radius, amount)
63
+ r_offset = 50 # The distance out from the center of the pie to get point
64
+ x_offset = center_x # The label points need to be tweaked slightly
65
+ y_offset = center_y + 0 # This one doesn't though
66
+ x = x_offset + ((radius + r_offset) * Math.cos(angle))
67
+ y = y_offset + ((radius + r_offset) * Math.sin(angle))
68
+
69
+ # Draw label
70
+ @d.fill = @marker_color
71
+ @d.font = @font if @font
72
+ @d.pointsize = scale_fontsize(legend_font_size)
73
+ @d.stroke = 'transparent'
74
+ @d.font_weight = BoldWeight
75
+ @d.gravity = CenterGravity
76
+ @d.annotate_scaled( @base_image,
77
+ 0, 0,
78
+ x, y,
79
+ amount, @scale)
80
+ end
81
+
82
+ def draw_axes(center_x, center_y, radius, additive_angle, line_color = nil)
83
+ return if hide_axes
84
+
85
+ current_angle = rotation * Math::PI / 180.0
86
+
87
+ @data.each do |data_row|
88
+ @d.stroke(line_color || data_row[DATA_COLOR_INDEX])
89
+ @d.stroke_width 5.0
90
+
91
+ x_offset = radius * Math.cos(current_angle)
92
+ y_offset = radius * Math.sin(current_angle)
93
+
94
+ @d.line(center_x, center_y,
95
+ center_x + x_offset,
96
+ center_y + y_offset)
97
+
98
+ draw_label(center_x, center_y, current_angle, radius, data_row[DATA_LABEL_INDEX].to_s) unless hide_text
99
+
100
+ current_angle += additive_angle
101
+ end
102
+ end
103
+
104
+ def draw_polygon(center_x, center_y, additive_angle, color = nil)
105
+ points = []
106
+ current_angle = rotation * Math::PI / 180.0
107
+
108
+ @data.each do |data_row|
109
+ points << center_x + normalize_points(data_row[DATA_VALUES_INDEX].first) * Math.cos(current_angle)
110
+ points << center_y + normalize_points(data_row[DATA_VALUES_INDEX].first) * Math.sin(current_angle)
111
+ current_angle += additive_angle
112
+ end
113
+
114
+ @d.stroke_width 1.0
115
+ @d.stroke(color || @marker_color)
116
+ @d.fill(color || @marker_color)
117
+ @d.fill_opacity 0.4
118
+ @d.polygon(*points)
119
+ end
120
+
121
+ def sums_for_spider
122
+ @data.inject(0.0) {|sum, data_row| sum + data_row[DATA_VALUES_INDEX].first}
123
+ end
124
+
125
+ end