pfsc-gruff 0.3.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (82) hide show
  1. data/History.txt +117 -0
  2. data/MIT-LICENSE +21 -0
  3. data/Manifest.txt +81 -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 +28 -0
  35. data/lib/gruff/accumulator_bar.rb +27 -0
  36. data/lib/gruff/area.rb +58 -0
  37. data/lib/gruff/bar.rb +87 -0
  38. data/lib/gruff/bar_conversion.rb +46 -0
  39. data/lib/gruff/base.rb +1123 -0
  40. data/lib/gruff/bullet.rb +109 -0
  41. data/lib/gruff/deprecated.rb +39 -0
  42. data/lib/gruff/dot.rb +113 -0
  43. data/lib/gruff/line.rb +135 -0
  44. data/lib/gruff/mini/bar.rb +37 -0
  45. data/lib/gruff/mini/legend.rb +109 -0
  46. data/lib/gruff/mini/pie.rb +36 -0
  47. data/lib/gruff/mini/side_bar.rb +35 -0
  48. data/lib/gruff/net.rb +140 -0
  49. data/lib/gruff/photo_bar.rb +100 -0
  50. data/lib/gruff/pie.rb +126 -0
  51. data/lib/gruff/scene.rb +209 -0
  52. data/lib/gruff/side_bar.rb +118 -0
  53. data/lib/gruff/side_stacked_bar.rb +77 -0
  54. data/lib/gruff/spider.rb +130 -0
  55. data/lib/gruff/stacked_area.rb +67 -0
  56. data/lib/gruff/stacked_bar.rb +57 -0
  57. data/lib/gruff/stacked_mixin.rb +23 -0
  58. data/rails_generators/gruff/gruff_generator.rb +63 -0
  59. data/rails_generators/gruff/templates/controller.rb +32 -0
  60. data/rails_generators/gruff/templates/functional_test.rb +24 -0
  61. data/test/gruff_test_case.rb +123 -0
  62. data/test/test_accumulator_bar.rb +50 -0
  63. data/test/test_area.rb +134 -0
  64. data/test/test_bar.rb +321 -0
  65. data/test/test_base.rb +8 -0
  66. data/test/test_bullet.rb +26 -0
  67. data/test/test_dot.rb +273 -0
  68. data/test/test_legend.rb +68 -0
  69. data/test/test_line.rb +556 -0
  70. data/test/test_mini_bar.rb +33 -0
  71. data/test/test_mini_pie.rb +26 -0
  72. data/test/test_mini_side_bar.rb +37 -0
  73. data/test/test_net.rb +230 -0
  74. data/test/test_photo.rb +41 -0
  75. data/test/test_pie.rb +154 -0
  76. data/test/test_scene.rb +100 -0
  77. data/test/test_side_bar.rb +29 -0
  78. data/test/test_sidestacked_bar.rb +89 -0
  79. data/test/test_spider.rb +216 -0
  80. data/test/test_stacked_area.rb +52 -0
  81. data/test/test_stacked_bar.rb +52 -0
  82. metadata +186 -0
@@ -0,0 +1,36 @@
1
+ ##
2
+ #
3
+ # Makes a small pie graph suitable for display at 200px or even smaller.
4
+ #
5
+ module Gruff
6
+ module Mini
7
+
8
+ class Pie < Gruff::Pie
9
+
10
+ include Gruff::Mini::Legend
11
+
12
+ def initialize_ivars
13
+ super
14
+
15
+ @hide_legend = true
16
+ @hide_title = true
17
+ @hide_line_numbers = true
18
+
19
+ @marker_font_size = 60.0
20
+ @legend_font_size = 60.0
21
+ end
22
+
23
+ def draw
24
+ expand_canvas_for_vertical_legend
25
+
26
+ super
27
+
28
+ draw_vertical_legend
29
+
30
+ @d.draw(@base_image)
31
+ end # def draw
32
+
33
+ end # class Pie
34
+
35
+ end
36
+ end
@@ -0,0 +1,35 @@
1
+ ##
2
+ #
3
+ # Makes a small pie graph suitable for display at 200px or even smaller.
4
+ #
5
+ module Gruff
6
+ module Mini
7
+
8
+ class SideBar < Gruff::SideBar
9
+
10
+ include Gruff::Mini::Legend
11
+
12
+ def initialize_ivars
13
+ super
14
+ @hide_legend = true
15
+ @hide_title = true
16
+ @hide_line_numbers = true
17
+
18
+ @marker_font_size = 50.0
19
+ @legend_font_size = 50.0
20
+ end
21
+
22
+ def draw
23
+ expand_canvas_for_vertical_legend
24
+
25
+ super
26
+
27
+ draw_vertical_legend
28
+
29
+ @d.draw(@base_image)
30
+ end
31
+
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,140 @@
1
+
2
+ require File.dirname(__FILE__) + '/base'
3
+
4
+ # Experimental!!! See also the Spider graph.
5
+ class Gruff::Net < Gruff::Base
6
+
7
+ # Hide parts of the graph to fit more datapoints, or for a different appearance.
8
+ attr_accessor :hide_dots
9
+
10
+ # Dimensions of lines and dots; calculated based on dataset size if left unspecified
11
+ attr_accessor :line_width
12
+ attr_accessor :dot_radius
13
+
14
+ def initialize(*args)
15
+ super
16
+
17
+ @hide_dots = false
18
+ @hide_line_numbers = true
19
+ end
20
+
21
+ def draw
22
+
23
+ super
24
+
25
+ return unless @has_data
26
+
27
+ @radius = @graph_height / 2.0
28
+ @center_x = @graph_left + (@graph_width / 2.0)
29
+ @center_y = @graph_top + (@graph_height / 2.0) - 10 # Move graph up a bit
30
+
31
+ @x_increment = @graph_width / (@column_count - 1).to_f
32
+ circle_radius = dot_radius ||
33
+ clip_value_if_greater_than(@columns / (@norm_data.first[DATA_VALUES_INDEX].size * 2.5), 5.0)
34
+
35
+ @d = @d.stroke_opacity 1.0
36
+ @d = @d.stroke_width line_width ||
37
+ clip_value_if_greater_than(@columns / (@norm_data.first[DATA_VALUES_INDEX].size * 4), 5.0)
38
+
39
+ if (defined?(@norm_baseline)) then
40
+ level = @graph_top + (@graph_height - @norm_baseline * @graph_height)
41
+ @d = @d.push
42
+ @d.stroke_color @baseline_color
43
+ @d.fill_opacity 0.0
44
+ @d.stroke_dasharray(10, 20)
45
+ @d.stroke_width 5
46
+ @d.line(@graph_left, level, @graph_left + @graph_width, level)
47
+ @d = @d.pop
48
+ end
49
+
50
+ @norm_data.each do |data_row|
51
+ prev_x = prev_y = nil
52
+ @d = @d.stroke data_row[DATA_COLOR_INDEX]
53
+ @d = @d.fill data_row[DATA_COLOR_INDEX]
54
+
55
+ data_row[DATA_VALUES_INDEX].each_with_index do |data_point, index|
56
+ next if data_point.nil?
57
+
58
+ rad_pos = index * Math::PI * 2 / @column_count
59
+ point_distance = data_point * @radius
60
+ start_x = @center_x + Math::sin(rad_pos) * point_distance
61
+ start_y = @center_y - Math::cos(rad_pos) * point_distance
62
+
63
+ next_index = index + 1 < data_row[DATA_VALUES_INDEX].length ? index + 1 : 0
64
+
65
+ next_rad_pos = next_index * Math::PI * 2 / @column_count
66
+ next_point_distance = data_row[DATA_VALUES_INDEX][next_index] * @radius
67
+ end_x = @center_x + Math::sin(next_rad_pos) * next_point_distance
68
+ end_y = @center_y - Math::cos(next_rad_pos) * next_point_distance
69
+
70
+ @d = @d.line(start_x, start_y, end_x, end_y)
71
+
72
+ @d = @d.circle(start_x, start_y, start_x - circle_radius, start_y) unless @hide_dots
73
+ end
74
+
75
+ end
76
+
77
+ @d.draw(@base_image)
78
+ end
79
+
80
+
81
+ # the lines connecting in the center, with the first line vertical
82
+ def draw_line_markers
83
+ return if @hide_line_markers
84
+
85
+
86
+ # have to do this here (AGAIN)... see draw() in this class
87
+ # because this funtion is called before the @radius, @center_x and @center_y are set
88
+ @radius = @graph_height / 2.0
89
+ @center_x = @graph_left + (@graph_width / 2.0)
90
+ @center_y = @graph_top + (@graph_height / 2.0) - 10 # Move graph up a bit
91
+
92
+
93
+ # Draw horizontal line markers and annotate with numbers
94
+ @d = @d.stroke(@marker_color)
95
+ @d = @d.stroke_width 1
96
+
97
+
98
+ (0..@column_count-1).each do |index|
99
+ rad_pos = index * Math::PI * 2 / @column_count
100
+
101
+ @d = @d.line(@center_x, @center_y, @center_x + Math::sin(rad_pos) * @radius, @center_y - Math::cos(rad_pos) * @radius)
102
+
103
+
104
+ marker_label = labels[index] ? labels[index].to_s : '000'
105
+
106
+ draw_label(@center_x, @center_y, rad_pos * 360 / (2 * Math::PI), @radius, marker_label)
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def draw_label(center_x, center_y, angle, radius, amount)
113
+ r_offset = 1.1
114
+ x_offset = center_x # + 15 # The label points need to be tweaked slightly
115
+ y_offset = center_y # + 0 # This one doesn't though
116
+ x = x_offset + (radius * r_offset * Math.sin(angle.deg2rad))
117
+ y = y_offset - (radius * r_offset * Math.cos(angle.deg2rad))
118
+
119
+ # Draw label
120
+ @d.fill = @marker_color
121
+ @d.font = @font if @font
122
+ @d.pointsize = scale_fontsize(20)
123
+ @d.stroke = 'transparent'
124
+ @d.font_weight = BoldWeight
125
+ @d.gravity = CenterGravity
126
+ @d.annotate_scaled(@base_image, 0, 0, x, y, amount, @scale)
127
+ end
128
+
129
+ end
130
+
131
+ # # This method is already in Float
132
+ # class Float
133
+ # # Used for degree => radian conversions
134
+ # def deg2rad
135
+ # self * (Math::PI/180.0)
136
+ # end
137
+ # end
138
+
139
+
140
+
@@ -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,126 @@
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
+ # Do not show labels for slices that are less than this percent. Use 0 to always show all labels.
22
+ # Defaults to 0
23
+ attr_accessor :hide_labels_less_than
24
+
25
+ def initialize_ivars
26
+ super
27
+ @zero_degree = 0.0
28
+ @hide_labels_less_than = 0.0
29
+ end
30
+
31
+ def draw
32
+ @hide_line_markers = true
33
+
34
+ super
35
+
36
+ return unless @has_data
37
+
38
+ diameter = @graph_height
39
+ radius = ([@graph_width, @graph_height].min / 2.0) * 0.8
40
+ top_x = @graph_left + (@graph_width - diameter) / 2.0
41
+ center_x = @graph_left + (@graph_width / 2.0)
42
+ center_y = @graph_top + (@graph_height / 2.0) - 10 # Move graph up a bit
43
+ total_sum = sums_for_pie()
44
+ prev_degrees = @zero_degree
45
+
46
+ # Use full data since we can easily calculate percentages
47
+ data = (@sort ? @data.sort{ |a, b| a[DATA_VALUES_INDEX].first <=> b[DATA_VALUES_INDEX].first } : @data)
48
+ data.each do |data_row|
49
+ if data_row[DATA_VALUES_INDEX].first > 0
50
+ @d = @d.stroke data_row[DATA_COLOR_INDEX]
51
+ @d = @d.fill 'transparent'
52
+ @d.stroke_width(radius) # stroke width should be equal to radius. we'll draw centered on (radius / 2)
53
+
54
+ current_degrees = (data_row[DATA_VALUES_INDEX].first / total_sum) * 360.0
55
+
56
+ # ellipse will draw the the stroke centered on the first two parameters offset by the second two.
57
+ # therefore, in order to draw a circle of the proper diameter we must center the stroke at
58
+ # half the radius for both x and y
59
+ @d = @d.ellipse(center_x, center_y,
60
+ radius / 2.0, radius / 2.0,
61
+ prev_degrees, prev_degrees + current_degrees + 0.5) # <= +0.5 'fudge factor' gets rid of the ugly gaps
62
+
63
+ half_angle = prev_degrees + ((prev_degrees + current_degrees) - prev_degrees) / 2
64
+
65
+ label_val = ((data_row[DATA_VALUES_INDEX].first / total_sum) * 100.0).round
66
+ unless label_val < @hide_labels_less_than
67
+ # End the string with %% to escape the single %.
68
+ # RMagick must use sprintf with the string and % has special significance.
69
+ label_string = label_val.to_s + '%%'
70
+ @d = draw_label(center_x,center_y, half_angle,
71
+ radius + (radius * TEXT_OFFSET_PERCENTAGE),
72
+ label_string)
73
+ end
74
+
75
+ prev_degrees += current_degrees
76
+ end
77
+ end
78
+
79
+ # TODO debug a circle where the text is drawn...
80
+
81
+ @d.draw(@base_image)
82
+ end
83
+
84
+ private
85
+
86
+ ##
87
+ # Labels are drawn around a slightly wider ellipse to give room for
88
+ # labels on the left and right.
89
+ def draw_label(center_x, center_y, angle, radius, amount)
90
+ # TODO Don't use so many hard-coded numbers
91
+ r_offset = 20.0 # The distance out from the center of the pie to get point
92
+ x_offset = center_x # + 15.0 # The label points need to be tweaked slightly
93
+ y_offset = center_y # This one doesn't though
94
+ radius_offset = (radius + r_offset)
95
+ ellipse_factor = radius_offset * 0.15
96
+ x = x_offset + ((radius_offset + ellipse_factor) * Math.cos(angle.deg2rad))
97
+ y = y_offset + (radius_offset * Math.sin(angle.deg2rad))
98
+
99
+ # Draw label
100
+ @d.fill = @font_color
101
+ @d.font = @font if @font
102
+ @d.pointsize = scale_fontsize(@marker_font_size)
103
+ @d.stroke = 'transparent'
104
+ @d.font_weight = BoldWeight
105
+ @d.gravity = CenterGravity
106
+ @d.annotate_scaled( @base_image,
107
+ 0, 0,
108
+ x, y,
109
+ amount, @scale)
110
+ end
111
+
112
+ def sums_for_pie
113
+ total_sum = 0.0
114
+ @data.collect {|data_row| total_sum += data_row[DATA_VALUES_INDEX].first }
115
+ total_sum
116
+ end
117
+
118
+ end
119
+
120
+ class Float
121
+ # Used for degree => radian conversions
122
+ def deg2rad
123
+ self * (Math::PI/180.0)
124
+ end
125
+ end
126
+
@@ -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