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,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