gruffy 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,271 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+
3
+ ##
4
+ # Here's how to make a Pie graph:
5
+ #
6
+ # g = Gruffy::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 Gruffy::Pie < Gruffy::Base
15
+
16
+ DEFAULT_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_writer :zero_degree
21
+
22
+ # Do not show labels for slices that are less than this percent. Use 0 to always show all labels.
23
+ # Defaults to 0
24
+ attr_writer :hide_labels_less_than
25
+
26
+ # Affect the distance between the percentages and the pie chart
27
+ # Defaults to 0.15
28
+ attr_writer :text_offset_percentage
29
+
30
+ ## Use values instead of percentages
31
+ attr_accessor :show_values_as_labels
32
+
33
+ def initialize_ivars
34
+ super
35
+
36
+ @show_values_as_labels = false
37
+ end
38
+
39
+ def zero_degree
40
+ @zero_degree ||= 0.0
41
+ end
42
+
43
+ def hide_labels_less_than
44
+ @hide_labels_less_than ||= 0.0
45
+ end
46
+
47
+ def text_offset_percentage
48
+ @text_offset_percentage ||= DEFAULT_TEXT_OFFSET_PERCENTAGE
49
+ end
50
+
51
+ def options
52
+ {
53
+ :zero_degree => zero_degree,
54
+ :hide_labels_less_than => hide_labels_less_than,
55
+ :text_offset_percentage => text_offset_percentage,
56
+ :show_values_as_labels => show_values_as_labels
57
+ }
58
+ end
59
+
60
+ def draw
61
+ hide_line_markers
62
+
63
+ super
64
+
65
+ return unless data_given?
66
+
67
+ slices.each do |slice|
68
+ if slice.value > 0
69
+ set_stroke_color slice
70
+ set_fill_color
71
+ set_stroke_width
72
+ set_drawing_points_for slice
73
+ process_label_for slice
74
+ update_chart_degrees_with slice.degrees
75
+ end
76
+ end
77
+
78
+ trigger_final_draw
79
+ end
80
+
81
+ private
82
+
83
+ def slices
84
+ @slices ||= begin
85
+ slices = @data.map { |data| slice_class.new(data, options) }
86
+
87
+ slices.sort_by(&:value) if @sort
88
+
89
+ total = slices.map(&:value).inject(:+).to_f
90
+ slices.each { |slice| slice.total = total }
91
+ end
92
+ end
93
+
94
+ # General Helper Methods
95
+
96
+ def hide_line_markers
97
+ @hide_line_markers = true
98
+ end
99
+
100
+ def data_given?
101
+ @has_data
102
+ end
103
+
104
+ def update_chart_degrees_with(degrees)
105
+ @chart_degrees = chart_degrees + degrees
106
+ end
107
+
108
+ def slice_class
109
+ PieSlice
110
+ end
111
+
112
+ # Spatial Value-Related Methods
113
+
114
+ def chart_degrees
115
+ @chart_degrees ||= zero_degree
116
+ end
117
+
118
+ def graph_height
119
+ @graph_height
120
+ end
121
+
122
+ def graph_width
123
+ @graph_width
124
+ end
125
+
126
+ def diameter
127
+ graph_height
128
+ end
129
+
130
+ def half_width
131
+ graph_width / 2.0
132
+ end
133
+
134
+ def half_height
135
+ graph_height / 2.0
136
+ end
137
+
138
+ def radius
139
+ @radius ||= ([graph_width, graph_height].min / 2.0) * 0.8
140
+ end
141
+
142
+ def center_x
143
+ @center_x ||= @graph_left + half_width
144
+ end
145
+
146
+ def center_y
147
+ @center_y ||= @graph_top + half_height - 10
148
+ end
149
+
150
+ def distance_from_center
151
+ 20.0
152
+ end
153
+
154
+ def radius_offset
155
+ radius + (radius * text_offset_percentage) + distance_from_center
156
+ end
157
+
158
+ def ellipse_factor
159
+ radius_offset * text_offset_percentage
160
+ end
161
+
162
+ # Label-Related Methods
163
+
164
+ def process_label_for(slice)
165
+ if slice.percentage >= hide_labels_less_than
166
+ x, y = label_coordinates_for slice
167
+
168
+ @d = draw_label(x, y, slice.label)
169
+ end
170
+ end
171
+
172
+ def label_coordinates_for(slice)
173
+ angle = chart_degrees + slice.degrees / 2
174
+
175
+ [x_label_coordinate(angle), y_label_coordinate(angle)]
176
+ end
177
+
178
+ def x_label_coordinate(angle)
179
+ center_x + ((radius_offset + ellipse_factor) * Math.cos(deg2rad(angle)))
180
+ end
181
+
182
+ def y_label_coordinate(angle)
183
+ center_y + (radius_offset * Math.sin(deg2rad(angle)))
184
+ end
185
+
186
+ # Drawing-Related Methods
187
+
188
+ def set_stroke_width
189
+ @d.stroke_width(radius)
190
+ end
191
+
192
+ def set_stroke_color(slice)
193
+ @d = @d.stroke slice.color
194
+ end
195
+
196
+ def set_fill_color
197
+ @d = @d.fill 'transparent'
198
+ end
199
+
200
+ def set_drawing_points_for(slice)
201
+ @d = @d.ellipse(
202
+ center_x,
203
+ center_y,
204
+ radius / 2.0,
205
+ radius / 2.0,
206
+ chart_degrees,
207
+ chart_degrees + slice.degrees + 0.5
208
+ )
209
+ end
210
+
211
+ def trigger_final_draw
212
+ @d.draw(@base_image)
213
+ end
214
+
215
+ def configure_label_styling
216
+ @d.fill = @font_color
217
+ @d.font = @font if @font
218
+ @d.pointsize = scale_fontsize(@marker_font_size)
219
+ @d.stroke = 'transparent'
220
+ @d.font_weight = BoldWeight
221
+ @d.gravity = CenterGravity
222
+ end
223
+
224
+ def draw_label(x, y, value)
225
+ configure_label_styling
226
+
227
+ @d.annotate_scaled(
228
+ @base_image,
229
+ 0,
230
+ 0,
231
+ x,
232
+ y,
233
+ value,
234
+ @scale
235
+ )
236
+ end
237
+
238
+ # Helper Classes
239
+
240
+ class PieSlice < Struct.new(:data_array, :options)
241
+ attr_accessor :total
242
+
243
+ def name
244
+ data_array[0]
245
+ end
246
+
247
+ def value
248
+ data_array[1].first
249
+ end
250
+
251
+ def color
252
+ data_array[2]
253
+ end
254
+
255
+ def size
256
+ @size ||= value / total
257
+ end
258
+
259
+ def percentage
260
+ @percentage ||= (size * 100.0).round
261
+ end
262
+
263
+ def degrees
264
+ @degrees ||= size * 360.0
265
+ end
266
+
267
+ def label
268
+ options[:show_values_as_labels] ? value.to_s : "#{percentage}%"
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,314 @@
1
+ require File.dirname(__FILE__) + '/base'
2
+
3
+ # Here's how to set up an XY Scatter Chart
4
+ #
5
+ # g = Gruffy::Scatter.new(800)
6
+ # g.data(:apples, [1,2,3,4], [4,3,2,1])
7
+ # g.data('oranges', [5,7,8], [4,1,7])
8
+ # g.write('test/output/scatter.png')
9
+ #
10
+ #
11
+ class Gruffy::Scatter < Gruffy::Base
12
+
13
+ # Maximum X Value. The value will get overwritten by the max in the
14
+ # datasets.
15
+ attr_accessor :maximum_x_value
16
+
17
+ # Minimum X Value. The value will get overwritten by the min in the
18
+ # datasets.
19
+ attr_accessor :minimum_x_value
20
+
21
+ # The number of vertical lines shown for reference
22
+ attr_accessor :marker_x_count
23
+
24
+ #~ # Draw a dashed horizontal line at the given y value
25
+ #~ attr_accessor :baseline_y_value
26
+
27
+ #~ # Color of the horizontal baseline
28
+ #~ attr_accessor :baseline_y_color
29
+
30
+ #~ # Draw a dashed horizontal line at the given y value
31
+ #~ attr_accessor :baseline_x_value
32
+
33
+ #~ # Color of the horizontal baseline
34
+ #~ attr_accessor :baseline_x_color
35
+
36
+ # Attributes to allow customising the size of the points
37
+ attr_accessor :circle_radius
38
+ attr_accessor :stroke_width
39
+
40
+ # Allow disabling the significant rounding when labeling the X axis
41
+ # This is useful when working with a small range of high values (for example, a date range of months, while seconds as units)
42
+ attr_accessor :disable_significant_rounding_x_axis
43
+
44
+ # Allow enabling vertical lines. When you have a lot of data, they can work great
45
+ attr_accessor :enable_vertical_line_markers
46
+
47
+ # Allow using vertical labels in the X axis (and setting the label margin)
48
+ attr_accessor :x_label_margin
49
+ attr_accessor :use_vertical_x_labels
50
+
51
+ # Allow passing lambdas to format labels
52
+ attr_accessor :y_axis_label_format
53
+ attr_accessor :x_axis_label_format
54
+
55
+
56
+ # Gruffy::Scatter takes the same parameters as the Gruffy::Line graph
57
+ #
58
+ # ==== Example
59
+ #
60
+ # g = Gruffy::Scatter.new
61
+ #
62
+ def initialize(*)
63
+ super
64
+
65
+ @baseline_x_color = @baseline_y_color = 'red'
66
+ @baseline_x_value = @baseline_y_value = nil
67
+ @circle_radius = nil
68
+ @disable_significant_rounding_x_axis = false
69
+ @enable_vertical_line_markers = false
70
+ @marker_x_count = nil
71
+ @maximum_x_value = @minimum_x_value = nil
72
+ @stroke_width = nil
73
+ @use_vertical_x_labels = false
74
+ @x_axis_label_format = nil
75
+ @x_label_margin = nil
76
+ @y_axis_label_format = nil
77
+ end
78
+
79
+ def setup_drawing
80
+ # TODO Need to get x-axis labels working. Current behavior will be to not allow.
81
+ @labels = {}
82
+
83
+ super
84
+
85
+ # Translate our values so that we can use the base methods for drawing
86
+ # the standard chart stuff
87
+ @column_count = @x_spread
88
+ end
89
+
90
+ def draw
91
+ super
92
+ return unless @has_data
93
+
94
+ # Check to see if more than one datapoint was given. NaN can result otherwise.
95
+ @x_increment = (@column_count > 1) ? (@graph_width / (@column_count - 1).to_f) : @graph_width
96
+
97
+ #~ if (defined?(@norm_y_baseline)) then
98
+ #~ level = @graph_top + (@graph_height - @norm_baseline * @graph_height)
99
+ #~ @d = @d.push
100
+ #~ @d.stroke_color @baseline_color
101
+ #~ @d.fill_opacity 0.0
102
+ #~ @d.stroke_dasharray(10, 20)
103
+ #~ @d.stroke_width 5
104
+ #~ @d.line(@graph_left, level, @graph_left + @graph_width, level)
105
+ #~ @d = @d.pop
106
+ #~ end
107
+
108
+ #~ if (defined?(@norm_x_baseline)) then
109
+
110
+ #~ end
111
+
112
+ @norm_data.each do |data_row|
113
+ data_row[DATA_VALUES_INDEX].each_with_index do |data_point, index|
114
+ x_value = data_row[DATA_VALUES_X_INDEX][index]
115
+ next if data_point.nil? || x_value.nil?
116
+
117
+ new_x = get_x_coord(x_value, @graph_width, @graph_left)
118
+ new_y = @graph_top + (@graph_height - data_point * @graph_height)
119
+
120
+ # Reset each time to avoid thin-line errors
121
+ @d = @d.stroke data_row[DATA_COLOR_INDEX]
122
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
123
+ @d = @d.stroke_opacity 1.0
124
+ @d = @d.stroke_width @stroke_width || clip_value_if_greater_than(@columns / (@norm_data.first[1].size * 4), 5.0)
125
+
126
+ circle_radius = @circle_radius || clip_value_if_greater_than(@columns / (@norm_data.first[1].size * 2.5), 5.0)
127
+ @d = @d.circle(new_x, new_y, new_x - circle_radius, new_y)
128
+ end
129
+ end
130
+
131
+ @d.draw(@base_image)
132
+ end
133
+
134
+ # The first parameter is the name of the dataset. The next two are the
135
+ # x and y axis data points contain in their own array in that respective
136
+ # order. The final parameter is the color.
137
+ #
138
+ # Can be called multiple times with different datasets for a multi-valued
139
+ # graph.
140
+ #
141
+ # If the color argument is nil, the next color from the default theme will
142
+ # be used.
143
+ #
144
+ # NOTE: If you want to use a preset theme, you must set it before calling
145
+ # data().
146
+ #
147
+ # ==== Parameters
148
+ # name:: String or Symbol containing the name of the dataset.
149
+ # x_data_points:: An Array of of x-axis data points.
150
+ # y_data_points:: An Array of of y-axis data points.
151
+ # color:: The hex string for the color of the dataset. Defaults to nil.
152
+ #
153
+ # ==== Exceptions
154
+ # Data points contain nil values::
155
+ # This error will get raised if either the x or y axis data points array
156
+ # contains a <tt>nil</tt> value. The graph will not make an assumption
157
+ # as how to graph <tt>nil</tt>
158
+ # x_data_points is empty::
159
+ # This error is raised when the array for the x-axis points are empty
160
+ # y_data_points is empty::
161
+ # This error is raised when the array for the y-axis points are empty
162
+ # x_data_points.length != y_data_points.length::
163
+ # Error means that the x and y axis point arrays do not match in length
164
+ #
165
+ # ==== Examples
166
+ # g = Gruffy::Scatter.new
167
+ # g.data(:apples, [1,2,3], [3,2,1])
168
+ # g.data('oranges', [1,1,1], [2,3,4])
169
+ # g.data('bitter_melon', [3,5,6], [6,7,8], '#000000')
170
+ #
171
+ def data(name, x_data_points=[], y_data_points=[], color=nil)
172
+
173
+ raise ArgumentError, 'Data Points contain nil Value!' if x_data_points.include?(nil) || y_data_points.include?(nil)
174
+ raise ArgumentError, 'x_data_points is empty!' if x_data_points.empty?
175
+ raise ArgumentError, 'y_data_points is empty!' if y_data_points.empty?
176
+ raise ArgumentError, 'x_data_points.length != y_data_points.length!' if x_data_points.length != y_data_points.length
177
+
178
+ # Call the existing data routine for the y axis data
179
+ super(name, y_data_points, color)
180
+
181
+ #append the x data to the last entry that was just added in the @data member
182
+ last_elem = @data.length()-1
183
+ @data[last_elem] << x_data_points
184
+
185
+ if @maximum_x_value.nil? && @minimum_x_value.nil?
186
+ @maximum_x_value = @minimum_x_value = x_data_points.first
187
+ end
188
+
189
+ @maximum_x_value = x_data_points.max > @maximum_x_value ?
190
+ x_data_points.max : @maximum_x_value
191
+ @minimum_x_value = x_data_points.min < @minimum_x_value ?
192
+ x_data_points.min : @minimum_x_value
193
+ end
194
+
195
+ protected
196
+
197
+ def calculate_spread #:nodoc:
198
+ super
199
+ @x_spread = @maximum_x_value.to_f - @minimum_x_value.to_f
200
+ @x_spread = @x_spread > 0 ? @x_spread : 1
201
+ end
202
+
203
+ def normalize(force=nil)
204
+ if @norm_data.nil? || force
205
+ @norm_data = []
206
+ return unless @has_data
207
+
208
+ @data.each do |data_row|
209
+ norm_data_points = [data_row[DATA_LABEL_INDEX]]
210
+ norm_data_points << data_row[DATA_VALUES_INDEX].map do |r|
211
+ (r.to_f - @minimum_value.to_f) / @spread
212
+ end
213
+ norm_data_points << data_row[DATA_COLOR_INDEX]
214
+ norm_data_points << data_row[DATA_VALUES_X_INDEX].map do |r|
215
+ (r.to_f - @minimum_x_value.to_f) / @x_spread
216
+ end
217
+ @norm_data << norm_data_points
218
+ end
219
+ end
220
+ #~ @norm_y_baseline = (@baseline_y_value.to_f / @maximum_value.to_f) if @baseline_y_value
221
+ #~ @norm_x_baseline = (@baseline_x_value.to_f / @maximum_x_value.to_f) if @baseline_x_value
222
+ end
223
+
224
+ def draw_line_markers
225
+ # do all of the stuff for the horizontal lines on the y-axis
226
+ super
227
+ return if @hide_line_markers
228
+
229
+ @d = @d.stroke_antialias false
230
+
231
+ if @x_axis_increment.nil?
232
+ # TODO Do the same for larger numbers...100, 75, 50, 25
233
+ if @marker_x_count.nil?
234
+ (3..7).each do |lines|
235
+ if @x_spread % lines == 0.0
236
+ @marker_x_count = lines
237
+ break
238
+ end
239
+ end
240
+ @marker_x_count ||= 4
241
+ end
242
+ @x_increment = (@x_spread > 0) ? (@x_spread / @marker_x_count) : 1
243
+ unless @disable_significant_rounding_x_axis
244
+ @x_increment = significant(@x_increment)
245
+ end
246
+ else
247
+ # TODO Make this work for negative values
248
+ @maximum_x_value = [@maximum_value.ceil, @x_axis_increment].max
249
+ @minimum_x_value = @minimum_x_value.floor
250
+ calculate_spread
251
+ normalize(true)
252
+
253
+ @marker_count = (@x_spread / @x_axis_increment).to_i
254
+ @x_increment = @x_axis_increment
255
+ end
256
+ @increment_x_scaled = @graph_width.to_f / (@x_spread / @x_increment)
257
+
258
+ # Draw vertical line markers and annotate with numbers
259
+ (0..@marker_x_count).each do |index|
260
+
261
+ # TODO Fix the vertical lines, and enable them by default. Not pretty when they don't match up with top y-axis line
262
+ if @enable_vertical_line_markers
263
+ x = @graph_left + @graph_width - index.to_f * @increment_x_scaled
264
+ @d = @d.stroke(@marker_color)
265
+ @d = @d.stroke_width 1
266
+ @d = @d.line(x, @graph_top, x, @graph_bottom)
267
+ end
268
+
269
+ unless @hide_line_numbers
270
+ marker_label = index * @x_increment + @minimum_x_value.to_f
271
+ y_offset = @graph_bottom + (@x_label_margin || LABEL_MARGIN)
272
+ x_offset = get_x_coord(index.to_f, @increment_x_scaled, @graph_left)
273
+
274
+ @d.fill = @font_color
275
+ @d.font = @font if @font
276
+ @d.stroke('transparent')
277
+ @d.pointsize = scale_fontsize(@marker_font_size)
278
+ @d.gravity = NorthGravity
279
+ @d.rotation = -90.0 if @use_vertical_x_labels
280
+ @d = @d.annotate_scaled(@base_image,
281
+ 1.0, 1.0,
282
+ x_offset, y_offset,
283
+ vertical_label(marker_label, @x_increment), @scale)
284
+ @d.rotation = 90.0 if @use_vertical_x_labels
285
+ end
286
+ end
287
+
288
+ @d = @d.stroke_antialias true
289
+ end
290
+
291
+
292
+ def label(value, increment)
293
+ if @y_axis_label_format
294
+ @y_axis_label_format.call(value)
295
+ else
296
+ super
297
+ end
298
+ end
299
+
300
+ def vertical_label(value, increment)
301
+ if @x_axis_label_format
302
+ @x_axis_label_format.call(value)
303
+ else
304
+ label(value, increment)
305
+ end
306
+ end
307
+
308
+ private
309
+
310
+ def get_x_coord(x_data_point, width, offset) #:nodoc:
311
+ x_data_point * width + offset
312
+ end
313
+
314
+ end # end Gruffy::Scatter