pfsc-gruff 0.3.6

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