geo_tools 1.0.1

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.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 [name of plugin creator]
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # GeoTools
2
+
3
+
4
+ You have lots of plugin choices if you want to geocode North American addresses, or find all the locations near somewhere. But few help you with forms and validation.
5
+
6
+ This plugin does four things:
7
+
8
+ * Adds `latitude_field` and `longitude_field` form helpers to Rails' default form builder.
9
+ * Lets your model acts_as_location, to work seamlessly with the form helpers.
10
+ * Validates the location data entered on the form and in the database.
11
+ * Gives you a `within` named scope to find all lcoations within a given bounding box, such as you would have on a Google map.
12
+
13
+
14
+ ## Assumptions
15
+
16
+ * Any model that acts_as_location has integers defined for each component of the latitude and longitude:
17
+
18
+ # In your model's migration's self.up method:
19
+ create_table :thingies do |t|
20
+ # Your model's various fields.
21
+ t.string :name
22
+ t.timestamps
23
+ ...
24
+
25
+ # Stuff GeoTools needs:
26
+ t.integer :latitude_degrees, :latitude_minutes, :latitude_decimal_minutes, :latitude_decimal_minutes_width
27
+ t.string :latitude_hemisphere
28
+ t.integer :longitude_degrees, :longitude_minutes, :longitude_decimal_minutes, :longitude_decimal_minutes_width
29
+ t.string :longitude_hemisphere
30
+ end
31
+
32
+ Storing the components separately like this avoids the round-trip rounding errors you get when using floating point numbers. If you need a floating point representation in the database, for example to use a mapping plugin, simply add an after_update callback to your model to write the float value to the database.
33
+
34
+ * A latitude should be entered on a form like this:
35
+
36
+ xx <degree symbol> yy <decimal point> zz h
37
+
38
+ where:
39
+
40
+ xx is degrees (0 <= integer <= 90; maximum length of 2 digits)
41
+ yy is minutes (0 <= integer <= 59; maximum length of 2 digits; optional; defaults to 0)
42
+ zz is decimal-minutes (0 <= integer <= 99; maximum length of 2 digits; optional; defaults to 0)
43
+ h is hemisphere ('N' or 'S')
44
+
45
+ Note with decimal minutes 2, 20 and 200000 are equivalent. This is because 3.2, 3.20 and 3.200000 are equivalent.
46
+
47
+ * Similarly, a longitude should be entered on a form like this:
48
+
49
+ xxx <degree symbol> yy <decimal point> zz h
50
+
51
+ where:
52
+
53
+ xxx is degrees (0 <= integer <= 180; maximum length of 3 digits)
54
+ yy is minutes (0 <= integer <= 59; maximum length of 2 digits; optional; defaults to 0)
55
+ zz is decimal-minutes (0 <= integer <= 99; maximum length of 2 digits; optional; defaults to 0)
56
+ h is hemisphere ('E' or 'W')
57
+
58
+
59
+ ## Example
60
+
61
+ # Model
62
+ class Treasure < ActiveRecord::Base
63
+ acts_as_location
64
+ end
65
+
66
+ # View
67
+ <% form_for @treasure do |f| %>
68
+ <%= f.text_field :spot_marked_by %>
69
+ <%= f.latitude_field :latitude %>
70
+ <%= f.longitude_field :longitude %>
71
+ <% end %>
72
+
73
+ # Controller
74
+ # ...same as usual...
75
+
76
+ You'll get validation on every field (degrees, minutes, decimal-minutes, hemisphere) generated by the form helpers, though not the overall value any more (TBD).
77
+
78
+ Here's an example script/console session:
79
+
80
+ >> puts Treasure.find(:first).location
81
+ 12°34.56′N, 012°34.56′W # N.B. If this looks weird online, set your browser's text encoding to UTF-8.
82
+
83
+ >> puts Treasure.find(:first).location.latitude
84
+ 12.576
85
+
86
+ >> puts Treasure.find(:first).location.longitude
87
+ -12.576
88
+
89
+
90
+ ## To Do
91
+
92
+ * Get tests to run transactionally so we don't have to clean out database in every single #setup method.
93
+ * Add a validation for the overall latitude and longitude values (to catch for example 90°00.01′N).
94
+ * Use `method` in the form helpers so user can give database columns different names (e.g. my_lat_degrees, etc).
95
+ See the way Paperclip allows different attachment names.
96
+ * DRY up form helper methods.
97
+ * DRY up location.rb.
98
+ * Investigate implementing with ActiveRecord's multiparameter assignment.
99
+
100
+
101
+ ## Feedback
102
+
103
+ Yes please! --> boss@airbladesoftware.com
104
+
105
+
106
+ ## Intellectual Property
107
+
108
+ Copyright (c) 2010 Andy Stewart, AirBlade Software Ltd. Released under the MIT license
data/Rakefile ADDED
@@ -0,0 +1,37 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+
5
+ desc 'Default: run unit tests.'
6
+ task :default => :test
7
+
8
+ desc 'Test the geo_tools plugin.'
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << 'lib'
11
+ t.libs << 'test'
12
+ t.pattern = 'test/**/*_test.rb'
13
+ t.verbose = true
14
+ end
15
+
16
+ desc 'Generate documentation for the geo_tools plugin.'
17
+ Rake::RDocTask.new(:rdoc) do |rdoc|
18
+ rdoc.rdoc_dir = 'rdoc'
19
+ rdoc.title = 'GeoTools'
20
+ rdoc.options << '--line-numbers' << '--inline-source'
21
+ rdoc.rdoc_files.include('README')
22
+ rdoc.rdoc_files.include('lib/**/*.rb')
23
+ end
24
+
25
+ begin
26
+ require 'jeweler'
27
+ Jeweler::Tasks.new do |gemspec|
28
+ gemspec.name = 'geo_tools'
29
+ gemspec.summary = 'View helpers, validations, and named scopes for locations.'
30
+ gemspec.email = 'boss@airbladesoftware.com'
31
+ gemspec.homepage = 'http://github.com/airblade/geo_tools'
32
+ gemspec.authors = ['Andy Stewart']
33
+ end
34
+ Jeweler::GemcutterTasks.new
35
+ rescue LoadError
36
+ puts 'Jeweler not available. Install it with: gem install jeweler'
37
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 1.0.1
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'geo_tools'
data/install.rb ADDED
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -0,0 +1,166 @@
1
+ module AirBlade
2
+ module GeoTools
3
+
4
+ module FormHelpers
5
+
6
+ # Options:
7
+ # :latitude
8
+ # :degrees
9
+ # :symbol
10
+ # :minutes
11
+ # :symbol
12
+ # :decimal_minutes
13
+ # :symbol
14
+ # :maxlength
15
+ #
16
+ # Assumes the latitude field is called 'latitude'.
17
+ #
18
+ # The 'method' argument is for consistency with other field helpers. We don't use it
19
+ # when using the normal Rails form builder.
20
+ #
21
+ # 1/100th of a minute of latitude (or equitorial longitude) is approximately 20m.
22
+ def latitude_field(method, options = {})
23
+ opts = {
24
+ :degrees => { :symbol => '&deg;' },
25
+ :minutes => { :symbol => '.' },
26
+ :decimal_minutes => { :symbol => '&prime;', :maxlength => 2 },
27
+ }
28
+ lat_options = options.delete :latitude
29
+ opts.merge! lat_options if lat_options
30
+
31
+ output = []
32
+
33
+ # Degrees
34
+ width = 2
35
+ output << plain_text_field("latitude_degrees",
36
+ options.merge(:maxlength => width,
37
+ :value => "%0#{width}d" % @object.send("latitude_degrees")))
38
+ output << opts[:degrees][:symbol]
39
+
40
+ # Minutes
41
+ width = 2
42
+ output << plain_text_field("latitude_minutes",
43
+ options.merge(:maxlength => width,
44
+ :value => "%0#{width}d" % @object.send("latitude_minutes")))
45
+ output << opts[:minutes][:symbol]
46
+
47
+ # Decimal minutes
48
+ width = opts[:decimal_minutes][:maxlength]
49
+ output << plain_text_field("latitude_decimal_minutes",
50
+ options.merge(:maxlength => width,
51
+ :value => @object.send("latitude_decimal_minutes_as_string").ljust(width, '0')))
52
+ output << opts[:decimal_minutes][:symbol]
53
+
54
+ # Hemisphere.
55
+ # Hmm, we pass the options in the html_options position.
56
+ output << plain_select("latitude_hemisphere", %w( N S ), {}, options)
57
+
58
+ output.join "\n"
59
+ end
60
+
61
+ def longitude_field(method, options = {})
62
+ opts = {
63
+ :degrees => { :symbol => '&deg;' },
64
+ :minutes => { :symbol => '.' },
65
+ :decimal_minutes => { :symbol => '&prime;', :maxlength => 2 },
66
+ }
67
+ long_options = options.delete :longitude
68
+ opts.merge! long_options if long_options
69
+
70
+ output = []
71
+
72
+ # Degrees
73
+ width = 3
74
+ output << plain_text_field("longitude_degrees",
75
+ options.merge(:maxlength => width,
76
+ :value => "%0#{width}d" % @object.send("longitude_degrees")))
77
+ output << opts[:degrees][:symbol]
78
+
79
+ # Minutes
80
+ width = 2
81
+ output << plain_text_field("longitude_minutes",
82
+ options.merge(:maxlength => width,
83
+ :value => "%0#{width}d" % @object.send("longitude_minutes")))
84
+ output << opts[:minutes][:symbol]
85
+
86
+ # Decimal minutes
87
+ width = opts[:decimal_minutes][:maxlength]
88
+ output << plain_text_field("longitude_decimal_minutes",
89
+ options.merge(:maxlength => width,
90
+ :value => @object.send("longitude_decimal_minutes_as_string").ljust(width, '0')))
91
+ output << opts[:decimal_minutes][:symbol]
92
+
93
+ # Hemisphere.
94
+ # Hmm, we pass the options in the html_options position.
95
+ output << plain_select("longitude_hemisphere", %w( E W ), {}, options)
96
+
97
+ output.join "\n"
98
+ end
99
+
100
+ # A layer of indirection to allow us always to use a plain field helpers,
101
+ # regardless of the form builder being used.
102
+
103
+ def plain_text_field(*a, &b)
104
+ text_field(*a, &b)
105
+ end
106
+
107
+ def plain_select(*a, &b)
108
+ select(*a, &b)
109
+ end
110
+ end
111
+
112
+
113
+ module AirBuddFormHelpers
114
+ include AirBlade::GeoTools::FormHelpers
115
+ alias_method :plain_latitude_field, :latitude_field
116
+ alias_method :plain_longitude_field, :longitude_field
117
+
118
+ # Override latitude_field to wrap it with the custom form builder gubbins.
119
+ # http://github.com/airblade/air_budd_form_builder/tree/master/lib/air_blade/air_budd/form_builder.rb
120
+ def latitude_field(method, options = {}, html_options = {})
121
+ @template.content_tag('p',
122
+ label_element(method, options, html_options) +
123
+ (
124
+ plain_latitude_field method, options
125
+ ) +
126
+ hint_element(options),
127
+ (errors_for?(method) ? {:class => 'error'} : {})
128
+ )
129
+ end
130
+
131
+ # Override longitude_field to wrap it with the custom form builder gubbins.
132
+ # http://github.com/airblade/air_budd_form_builder/tree/master/lib/air_blade/air_budd/form_builder.rb
133
+ def longitude_field(method, options = {}, html_options = {})
134
+ @template.content_tag('p',
135
+ label_element(method, options, html_options) +
136
+ (
137
+ plain_longitude_field method, options
138
+ ) +
139
+ hint_element(options),
140
+ (errors_for?(method) ? {:class => 'error'} : {})
141
+ )
142
+ end
143
+
144
+ # Use the standard Rails helpers for text fields and selects.
145
+ # These are overridden by the AirBudd form builder, so we define
146
+ # them ourselves.
147
+
148
+ def plain_text_field(method, options = {})
149
+ # From ActionView::Helpers::FormBuilder
150
+ @template.send('text_field', @object_name, method, objectify_options(options))
151
+ end
152
+ def plain_select(method, choices, options = {}, html_options = {})
153
+ # From ActionView::Helpers::FormOptionsHelper::FormBuilder
154
+ @template.select(@object_name, method, choices, objectify_options(options), @default_options.merge(html_options))
155
+ end
156
+ end
157
+
158
+ end
159
+ end
160
+
161
+
162
+ # Integrate with standard Rails form builder.
163
+ ActionView::Helpers::FormBuilder.send :include, AirBlade::GeoTools::FormHelpers
164
+
165
+ # Integrate with custom AirBudd form builder.
166
+ AirBlade::AirBudd::FormBuilder.send(:include, AirBlade::GeoTools::AirBuddFormHelpers) rescue nil
@@ -0,0 +1,266 @@
1
+ module AirBlade
2
+ module GeoTools
3
+ module Location
4
+
5
+ def self.included(base)
6
+ # Lazy loading pattern.
7
+ base.extend ActMethods
8
+ end
9
+
10
+ module ActMethods
11
+ def acts_as_location
12
+ unless included_modules.include? InstanceMethods
13
+ extend ClassMethods
14
+ include InstanceMethods
15
+
16
+ code = <<-END
17
+ validates_numericality_of_for :latitude_degrees,
18
+ :only_integer => true,
19
+ :greater_than_or_equal_to => 0,
20
+ :less_than_or_equal_to => 90,
21
+ :message => 'Degrees are invalid',
22
+ :for => :latitude
23
+
24
+ validates_numericality_of_for :latitude_minutes,
25
+ :only_integer => true,
26
+ :greater_than_or_equal_to => 0,
27
+ :less_than => 60,
28
+ :message => 'Minutes are invalid',
29
+ :for => :latitude
30
+
31
+ validates_numericality_of_for :latitude_decimal_minutes,
32
+ :only_integer => true,
33
+ :greater_than_or_equal_to => 0,
34
+ :message => 'Decimal minutes are invalid',
35
+ :for => :latitude
36
+
37
+ validates_numericality_of_for :latitude_decimal_minutes_width,
38
+ :only_integer => true,
39
+ :greater_than_or_equal_to => 0,
40
+ :for => :latitude
41
+
42
+ validates_inclusion_of_for :latitude_hemisphere,
43
+ :in => %w( N S ),
44
+ :message => 'Hemisphere is invalid',
45
+ :for => :latitude
46
+
47
+ validates_numericality_of_for :longitude_degrees,
48
+ :only_integer => true,
49
+ :greater_than_or_equal_to => 0,
50
+ :less_than_or_equal_to => 180,
51
+ :message => 'Degrees are invalid',
52
+ :for => :longitude
53
+
54
+ validates_numericality_of_for :longitude_minutes,
55
+ :only_integer => true,
56
+ :greater_than_or_equal_to => 0,
57
+ :less_than => 60,
58
+ :message => 'Minutes are invalid',
59
+ :for => :longitude
60
+
61
+ validates_numericality_of_for :longitude_decimal_minutes,
62
+ :only_integer => true,
63
+ :greater_than_or_equal_to => 0,
64
+ :message => 'Decimal minutes are invalid',
65
+ :for => :longitude
66
+
67
+ validates_numericality_of_for :longitude_decimal_minutes_width,
68
+ :only_integer => true,
69
+ :greater_than_or_equal_to => 0,
70
+ :for => :longitude
71
+
72
+ validates_inclusion_of_for :longitude_hemisphere,
73
+ :in => %w( E W ),
74
+ :message => 'Hemisphere is invalid',
75
+ :for => :longitude
76
+
77
+ before_validation :set_empty_values
78
+ END
79
+ class_eval code, __FILE__, __LINE__
80
+
81
+ # Returns all locations within the given bounding box, to an accuracy of 1 minute.
82
+ #
83
+ # This is useful for finding all locations within the area covered by a Google map.
84
+ #
85
+ # The parameters should be positive/negative floats.
86
+ named_scope :within, lambda { |sw_lat, sw_lng, ne_lat, ne_lng|
87
+ sw_lat_degs = sw_lat.to_i.abs
88
+ sw_lat_mins = ((sw_lat - sw_lat.to_i) * 60.0).round.abs
89
+ ne_lat_degs = ne_lat.to_i.abs
90
+ ne_lat_mins = ((ne_lat - ne_lat.to_i) * 60.0).round.abs
91
+
92
+ sw_lng_degs = sw_lng.to_i.abs
93
+ sw_lng_mins = ((sw_lng - sw_lng.to_i) * 60.0).round.abs
94
+ ne_lng_degs = ne_lng.to_i.abs
95
+ ne_lng_mins = ((ne_lng - ne_lng.to_i) * 60.0).round.abs
96
+
97
+ # Latitude conditions.
98
+ if sw_lat > 0 && ne_lat > 0 # northern hemisphere
99
+ condition_lat_h = 'latitude_hemisphere = "N"'
100
+ condition_lat_sw = ["(latitude_degrees > ?) OR (latitude_degrees = ? AND latitude_minutes >= ?)", sw_lat_degs, sw_lat_degs, sw_lat_mins]
101
+ condition_lat_ne = ["(latitude_degrees < ?) OR (latitude_degrees = ? AND latitude_minutes <= ?)", ne_lat_degs, ne_lat_degs, ne_lat_mins]
102
+ condition_lat = merge_conditions condition_lat_h, condition_lat_sw, condition_lat_ne
103
+
104
+ elsif sw_lat < 0 && ne_lat < 0 # southern hemisphere
105
+ condition_lat_h = 'latitude_hemisphere = "S"'
106
+ condition_lat_sw = ["(latitude_degrees < ?) OR (latitude_degrees = ? AND latitude_minutes <= ?)", sw_lat_degs, sw_lat_degs, sw_lat_mins]
107
+ condition_lat_ne = ["(latitude_degrees > ?) OR (latitude_degrees = ? AND latitude_minutes >= ?)", ne_lat_degs, ne_lat_degs, ne_lat_mins]
108
+ condition_lat = merge_conditions condition_lat_h, condition_lat_sw, condition_lat_ne
109
+
110
+ elsif sw_lat <= 0 && ne_lat >= 0 # straddles equator
111
+ condition_lat_h = 'latitude_hemisphere = "S"'
112
+ condition_lat_sw = ["(latitude_degrees < ?) OR (latitude_degrees = ? AND latitude_minutes <= ?)", sw_lat_degs, sw_lat_degs, sw_lat_mins]
113
+ condition_lat_s = merge_conditions condition_lat_h, condition_lat_sw
114
+
115
+ condition_lat_h = 'latitude_hemisphere = "N"'
116
+ condition_lat_ne = ["(latitude_degrees < ?) OR (latitude_degrees = ? AND latitude_minutes <= ?)", ne_lat_degs, ne_lat_degs, ne_lat_mins]
117
+ condition_lat_n = merge_conditions condition_lat_h, condition_lat_ne
118
+
119
+ condition_lat = merge_or_conditions condition_lat_s, condition_lat_n
120
+ end
121
+
122
+ # Longitude conditions.
123
+ if sw_lng > 0 && ne_lng > 0 # eastern hemisphere
124
+ condition_lng_h = 'longitude_hemisphere = "E"'
125
+ condition_lng_sw = ["(longitude_degrees > ?) OR (longitude_degrees = ? AND longitude_minutes >= ?)", sw_lng_degs, sw_lng_degs, sw_lng_mins]
126
+ condition_lng_ne = ["(longitude_degrees < ?) OR (longitude_degrees = ? AND longitude_minutes <= ?)", ne_lng_degs, ne_lng_degs, ne_lng_mins]
127
+ condition_lng = merge_conditions condition_lng_h, condition_lng_sw, condition_lng_ne
128
+
129
+ elsif sw_lng < 0 && ne_lng < 0 # western hemisphere
130
+ condition_lng_h = 'longitude_hemisphere = "W"'
131
+ condition_lng_sw = ["(longitude_degrees < ?) OR (longitude_degrees = ? AND longitude_minutes <= ?)", sw_lng_degs, sw_lng_degs, sw_lng_mins]
132
+ condition_lng_ne = ["(longitude_degrees > ?) OR (longitude_degrees = ? AND longitude_minutes >= ?)", ne_lng_degs, ne_lng_degs, ne_lng_mins]
133
+ condition_lng = merge_conditions condition_lng_h, condition_lng_sw, condition_lng_ne
134
+
135
+ elsif sw_lng <= 0 && ne_lng >= 0 # straddles prime meridian
136
+ condition_lng_h = 'longitude_hemisphere = "W"'
137
+ condition_lng_sw = ["(longitude_degrees < ?) OR (longitude_degrees = ? AND longitude_minutes <= ?)", sw_lng_degs, sw_lng_degs, sw_lng_mins]
138
+ condition_lng_w = merge_conditions condition_lng_h, condition_lng_sw
139
+
140
+ condition_lng_h = 'longitude_hemisphere = "E"'
141
+ condition_lng_ne = ["(longitude_degrees < ?) OR (longitude_degrees = ? AND longitude_minutes <= ?)", ne_lng_degs, ne_lng_degs, ne_lng_mins]
142
+ condition_lng_e = merge_conditions condition_lng_h, condition_lng_ne
143
+
144
+ condition_lng = merge_or_conditions condition_lng_w, condition_lng_e
145
+ end
146
+
147
+ # Combined latitude and longitude conditions.
148
+ {:conditions => merge_conditions(condition_lat, condition_lng)}
149
+ }
150
+
151
+ end
152
+ end
153
+ end
154
+
155
+ module ClassMethods
156
+ # Merges conditions so that the result is a valid +condition+.
157
+ # Adapted from ActiveRecord::Base#merge_conditions.
158
+ def merge_or_conditions(*conditions)
159
+ segments = []
160
+
161
+ conditions.each do |condition|
162
+ unless condition.blank?
163
+ sql = sanitize_sql(condition)
164
+ segments << sql unless sql.blank?
165
+ end
166
+ end
167
+
168
+ "(#{segments.join(') OR (')})" unless segments.empty?
169
+ end
170
+ end
171
+
172
+ module InstanceMethods
173
+
174
+ def latitude_decimal_minutes=(value)
175
+ unless value.nil?
176
+ width = value.to_s.length
177
+ value = value.to_i
178
+
179
+ write_attribute :latitude_decimal_minutes, value
180
+ write_attribute :latitude_decimal_minutes_width, width
181
+ end
182
+ end
183
+
184
+ def latitude_decimal_minutes_as_string
185
+ "%0#{latitude_decimal_minutes_width}d" % latitude_decimal_minutes
186
+ end
187
+
188
+ def longitude_decimal_minutes=(value)
189
+ unless value.nil?
190
+ width = value.to_s.length
191
+ value = value.to_i
192
+
193
+ write_attribute :longitude_decimal_minutes, value
194
+ write_attribute :longitude_decimal_minutes_width, width
195
+ end
196
+ end
197
+
198
+ def longitude_decimal_minutes_as_string
199
+ "%0#{longitude_decimal_minutes_width}d" % longitude_decimal_minutes
200
+ end
201
+
202
+ def latitude
203
+ to_float latitude_degrees, latitude_minutes, latitude_decimal_minutes,
204
+ latitude_decimal_minutes_width, latitude_hemisphere
205
+ end
206
+
207
+ def longitude
208
+ to_float longitude_degrees, longitude_minutes, longitude_decimal_minutes,
209
+ longitude_decimal_minutes_width, longitude_hemisphere
210
+ end
211
+
212
+ def to_s
213
+ # Unicode degree symbol, full stop, Unicode minute symbol.
214
+ units = [ "\xc2\xb0", '.', "\xe2\x80\xb2" ]
215
+
216
+ lat_fields = ["%02d" % latitude_degrees,
217
+ "%02d" % latitude_minutes,
218
+ latitude_decimal_minutes_as_string.ljust(2, '0'),
219
+ latitude_hemisphere]
220
+ lat = lat_fields.zip(units).map{ |f| f.join }.join
221
+
222
+ long_fields = ["%02d" % longitude_degrees,
223
+ "%02d" % longitude_minutes,
224
+ longitude_decimal_minutes_as_string.ljust(2, '0'),
225
+ longitude_hemisphere]
226
+ long = long_fields.zip(units).map{ |f| f.join }.join
227
+
228
+ "#{lat}, #{long}"
229
+ end
230
+
231
+ private
232
+
233
+ def to_float(degrees, minutes, decimal_minutes, decimal_minutes_width, hemisphere)
234
+ return nil if degrees.nil? and minutes.nil? and decimal_minutes.nil?
235
+ degrees ||= 0
236
+ minutes ||= 0
237
+ decimal_minutes ||= 0
238
+
239
+ f = degrees.to_f
240
+ f = f + (minutes.to_f + decimal_minutes.to_f / 10 ** decimal_minutes_width) / 60.0
241
+ f = f * -1 if hemisphere == 'S' or hemisphere == 'W'
242
+ f
243
+ end
244
+
245
+ # If some of the fields are empty, set them to zero. This is to speed up data entry.
246
+ # If all the fields are empty, leave them empty.
247
+ def set_empty_values
248
+ unless latitude_degrees.blank? and latitude_minutes.blank? and latitude_decimal_minutes.blank?
249
+ self.latitude_degrees = 0 if latitude_degrees.blank?
250
+ self.latitude_minutes = 0 if latitude_minutes.blank?
251
+ self.latitude_decimal_minutes = 0 if latitude_decimal_minutes.blank?
252
+ end
253
+
254
+ unless longitude_degrees.blank? and longitude_minutes.blank? and longitude_decimal_minutes.blank?
255
+ self.longitude_degrees = 0 if longitude_degrees.blank?
256
+ self.longitude_minutes = 0 if longitude_minutes.blank?
257
+ self.longitude_decimal_minutes = 0 if longitude_decimal_minutes.blank?
258
+ end
259
+ end
260
+ end
261
+
262
+ end
263
+ end
264
+ end
265
+
266
+ ActiveRecord::Base.send :include, AirBlade::GeoTools::Location
@@ -0,0 +1,75 @@
1
+ module AirBlade
2
+ module GeoTools
3
+ module Validations
4
+
5
+ # Sames as validates_numericality_of but additionally supports :for option
6
+ # which lets you attach an error to a different attribute.
7
+ def validates_inclusion_of_for(*attr_names)
8
+ configuration = { :on => :save }
9
+ configuration.update(attr_names.extract_options!)
10
+
11
+ enum = configuration[:in] || configuration[:within]
12
+
13
+ raise(ArgumentError, "An object with the method include? is required must be supplied as the :in option of the configuration hash") unless enum.respond_to?(:include?)
14
+
15
+ validates_each(attr_names, configuration) do |record, attr_name, value|
16
+ unless enum.include?(value)
17
+ attr_for = configuration[:for] || attr_name
18
+ record.errors.add(attr_for, :inclusion, :default => configuration[:message], :value => value)
19
+ end
20
+ end
21
+ end
22
+
23
+ # Sames as validates_numericality_of but additionally supports :for option
24
+ # which lets you attach an error to a different attribute.
25
+ def validates_numericality_of_for(*attr_names)
26
+ configuration = { :on => :save, :only_integer => false, :allow_nil => false }
27
+ configuration.update(attr_names.extract_options!)
28
+
29
+ numericality_options = ActiveRecord::Validations::ClassMethods::ALL_NUMERICALITY_CHECKS.keys & configuration.keys
30
+
31
+ (numericality_options - [ :odd, :even ]).each do |option|
32
+ raise ArgumentError, ":#{option} must be a number" unless configuration[option].is_a?(Numeric)
33
+ end
34
+
35
+ validates_each(attr_names,configuration) do |record, attr_name, value|
36
+ raw_value = record.send("#{attr_name}_before_type_cast") || value
37
+
38
+ next if configuration[:allow_nil] and raw_value.nil?
39
+
40
+ attr_for = configuration[:for] || attr_name
41
+
42
+ if configuration[:only_integer]
43
+ unless raw_value.to_s =~ /\A[+-]?\d+\Z/
44
+ record.errors.add(attr_for, :not_a_number, :value => raw_value, :default => configuration[:message])
45
+ next
46
+ end
47
+ raw_value = raw_value.to_i
48
+ else
49
+ begin
50
+ raw_value = Kernel.Float(raw_value)
51
+ rescue ArgumentError, TypeError
52
+ record.errors.add(attr_for, :not_a_number, :value => raw_value, :default => configuration[:message])
53
+ next
54
+ end
55
+ end
56
+
57
+ numericality_options.each do |option|
58
+ case option
59
+ when :odd, :even
60
+ unless raw_value.to_i.method( ActiveRecord::Validations::ClassMethods::ALL_NUMERICALITY_CHECKS[option])[]
61
+ record.errors.add(attr_for, option, :value => raw_value, :default => configuration[:message])
62
+ end
63
+ else
64
+ record.errors.add(attr_for, option, :default => configuration[:message], :value => raw_value, :count => configuration[option]) unless raw_value.method( ActiveRecord::Validations::ClassMethods::ALL_NUMERICALITY_CHECKS[option])[configuration[option]]
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ end
71
+ end
72
+ end
73
+
74
+
75
+ ActiveRecord::Base.send :extend, AirBlade::GeoTools::Validations
data/lib/geo_tools.rb ADDED
@@ -0,0 +1,3 @@
1
+ require File.dirname(__FILE__) + '/air_blade/geo_tools/validations'
2
+ require File.dirname(__FILE__) + '/air_blade/geo_tools/location'
3
+ require File.dirname(__FILE__) + '/air_blade/geo_tools/form_helpers'
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :geo_tools do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,160 @@
1
+ require 'test_helper'
2
+
3
+ class Treasure < ActiveRecord::Base
4
+ acts_as_location
5
+ end
6
+
7
+ class GeoToolsTest < ActiveSupport::TestCase
8
+
9
+ context 'A location model' do
10
+ setup { @treasure = Treasure.new }
11
+
12
+ should 'convert northern hemisphere latitude fields to a positive float' do
13
+ @treasure.update_attributes location
14
+ assert_in_delta 42.95583, @treasure.latitude, 0.0001
15
+ end
16
+
17
+ should 'convert southern hemisphere latitude fields to a negative float' do
18
+ @treasure.update_attributes location(:latitude_hemisphere => 'S')
19
+ assert_in_delta -42.95583, @treasure.latitude, 0.0001
20
+ end
21
+
22
+ should 'convert eastern hemisphere longitude fields to a positive float' do
23
+ @treasure.update_attributes location
24
+ assert_in_delta 153.37117, @treasure.longitude, 0.0001
25
+ end
26
+
27
+ should 'convert western hemisphere longitude fields to a negative float' do
28
+ @treasure.update_attributes location(:longitude_hemisphere => 'W')
29
+ assert_in_delta -153.37117, @treasure.longitude, 0.0001
30
+ end
31
+
32
+ should 'display a pretty #to_s' do
33
+ @treasure.update_attributes location
34
+ assert_equal "42°57.35′N, 153°22.27′E", @treasure.to_s
35
+ end
36
+
37
+ teardown { Treasure.destroy_all }
38
+ end
39
+
40
+ context 'Location#within' do
41
+ # TODO: use Factory Girl.
42
+
43
+ context 'NE quadrant' do
44
+ setup do
45
+ @a = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'N', :longitude_degrees => '153', :longitude_hemisphere => 'E', :latitude_minutes => '12', :longitude_minutes => '47'
46
+ @b = Treasure.create :latitude_degrees => '43', :latitude_hemisphere => 'N', :longitude_degrees => '153', :longitude_hemisphere => 'E'
47
+ @c = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'N', :longitude_degrees => '154', :longitude_hemisphere => 'E'
48
+ end
49
+ should 'return locations to nearest minute' do
50
+ assert_same_elements [], Treasure.within(1, 1, 42, 153)
51
+ assert_same_elements [@a, @b, @c], Treasure.within(1, 1, 43, 154)
52
+ assert_same_elements [@c], Treasure.within(1, 1, f(42, 11), 154)
53
+ assert_same_elements [@a, @c], Treasure.within(1, 1, f(42, 12), 154)
54
+ assert_same_elements [@b], Treasure.within(1, 1, 43, f(153, 46))
55
+ assert_same_elements [@a, @b], Treasure.within(1, 1, 43, f(153, 47))
56
+ end
57
+ teardown { Treasure.destroy_all }
58
+ end
59
+
60
+ context 'NW quadrant' do
61
+ setup do
62
+ @a = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'N', :longitude_degrees => '153', :longitude_hemisphere => 'W', :latitude_minutes => '12', :longitude_minutes => '47'
63
+ @b = Treasure.create :latitude_degrees => '43', :latitude_hemisphere => 'N', :longitude_degrees => '153', :longitude_hemisphere => 'W'
64
+ @c = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'N', :longitude_degrees => '154', :longitude_hemisphere => 'W'
65
+ end
66
+ should 'return locations to nearest minute' do
67
+ assert_same_elements [], Treasure.within(1, -153, 42, -1)
68
+ assert_same_elements [@a, @b, @c], Treasure.within(1, -154, 43, -1)
69
+ assert_same_elements [@c], Treasure.within(1, -154, f(42, 11), -1)
70
+ assert_same_elements [@a, @c], Treasure.within(1, -154, f(42, 12), -1)
71
+ assert_same_elements [@b], Treasure.within(1, f(-153, 46), 43, -1)
72
+ assert_same_elements [@a, @b], Treasure.within(1, f(-153, 47), 43, -1)
73
+ end
74
+ teardown { Treasure.destroy_all }
75
+ end
76
+
77
+ context 'SE quadrant' do
78
+ setup do
79
+ @a = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'S', :longitude_degrees => '153', :longitude_hemisphere => 'E', :latitude_minutes => '12', :longitude_minutes => '47'
80
+ @b = Treasure.create :latitude_degrees => '43', :latitude_hemisphere => 'S', :longitude_degrees => '153', :longitude_hemisphere => 'E'
81
+ @c = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'S', :longitude_degrees => '154', :longitude_hemisphere => 'E'
82
+ end
83
+ should 'return locations to nearest minute' do
84
+ assert_same_elements [], Treasure.within(-42, 1, -1, 153)
85
+ assert_same_elements [@a, @b, @c], Treasure.within(-43, 1, -1, 154)
86
+ assert_same_elements [@c], Treasure.within(f(-42, 11), 1, -1, 154)
87
+ assert_same_elements [@a, @c], Treasure.within(f(-42, 12), 1, -1, 154)
88
+ assert_same_elements [@b], Treasure.within(-43, 1, -1, f(153, 46))
89
+ assert_same_elements [@a, @b], Treasure.within(-43, 1, -1, f(153, 47))
90
+ end
91
+ teardown { Treasure.destroy_all }
92
+ end
93
+
94
+ context 'SW quadrant' do
95
+ setup do
96
+ @a = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'S', :longitude_degrees => '153', :longitude_hemisphere => 'W', :latitude_minutes => '12', :longitude_minutes => '47'
97
+ @b = Treasure.create :latitude_degrees => '43', :latitude_hemisphere => 'S', :longitude_degrees => '153', :longitude_hemisphere => 'W'
98
+ @c = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'S', :longitude_degrees => '154', :longitude_hemisphere => 'W'
99
+ end
100
+ should 'return locations to nearest minute' do
101
+ assert_same_elements [], Treasure.within(-42, -153, -1, -1)
102
+ assert_same_elements [@a, @b, @c], Treasure.within(-43, -154, -1, -1)
103
+ assert_same_elements [@c], Treasure.within(f(-42, 11), -154, -1, -1)
104
+ assert_same_elements [@a, @c], Treasure.within(f(-42, 12), -154, -1, -1)
105
+ assert_same_elements [@b], Treasure.within(-43, f(-153, 46), -1, -1)
106
+ assert_same_elements [@a, @b], Treasure.within(-43, f(-153, 47), -1, -1)
107
+ end
108
+ teardown { Treasure.destroy_all }
109
+ end
110
+
111
+ context 'straddling equator and prime meridian' do
112
+ setup do
113
+ @a = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'N', :longitude_degrees => '153', :longitude_hemisphere => 'E', :latitude_minutes => '12', :longitude_minutes => '47'
114
+ @b = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'N', :longitude_degrees => '153', :longitude_hemisphere => 'W', :latitude_minutes => '12', :longitude_minutes => '47'
115
+ @c = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'S', :longitude_degrees => '153', :longitude_hemisphere => 'E', :latitude_minutes => '12', :longitude_minutes => '47'
116
+ @d = Treasure.create :latitude_degrees => '42', :latitude_hemisphere => 'S', :longitude_degrees => '153', :longitude_hemisphere => 'W', :latitude_minutes => '12', :longitude_minutes => '47'
117
+ end
118
+ should 'return locations to nearest degree' do
119
+ assert_same_elements [], Treasure.within(-42, -153, 42, 153)
120
+ assert_same_elements [@a, @b, @c, @d], Treasure.within(-43, -154, 43, 154)
121
+
122
+ assert_same_elements [@a, @b], Treasure.within(f(-42, 11), -154, 43, 154)
123
+ assert_same_elements [@a, @b, @c, @d], Treasure.within(f(-42, 12), -154, 43, 154)
124
+
125
+ assert_same_elements [@a, @c], Treasure.within(-43, f(-153, 46), 43, 154)
126
+ assert_same_elements [@a, @b, @c, @d], Treasure.within(-43, f(-153, 47), 43, 154)
127
+
128
+ assert_same_elements [@c, @d], Treasure.within(-43, -154, f(42, 11), 154)
129
+ assert_same_elements [@a, @b, @c, @d], Treasure.within(-43, -154, f(42, 12), 154)
130
+
131
+ assert_same_elements [@b, @d], Treasure.within(-43, -154, 43, f(153, 46))
132
+ assert_same_elements [@a, @b, @c, @d], Treasure.within(-43, -154, 43, f(153, 47))
133
+ end
134
+ teardown { Treasure.destroy_all }
135
+ end
136
+ end
137
+
138
+
139
+ private
140
+
141
+ # TODO: use FactoryGirl instead.
142
+
143
+ def location(params = {})
144
+ { :latitude_degrees => 42,
145
+ :latitude_minutes => 57,
146
+ :latitude_decimal_minutes => 35,
147
+ :latitude_hemisphere => 'N',
148
+ :longitude_degrees => 153,
149
+ :longitude_minutes => 22,
150
+ :longitude_decimal_minutes => 27,
151
+ :longitude_hemisphere => 'E' }.merge params
152
+ end
153
+
154
+ # Degrees: positive or negative.
155
+ # Minutes: always positive.
156
+ def f(degrees, minutes = 0)
157
+ degrees >= 0 ? degrees + (minutes / 60.0) : degrees - (minutes / 60.0)
158
+ end
159
+
160
+ end
data/test/schema.rb ADDED
@@ -0,0 +1,9 @@
1
+ ActiveRecord::Schema.define(:version => 0) do
2
+ create_table :treasures, :force => true do |t|
3
+ t.string :name
4
+ t.integer :latitude_degrees, :latitude_minutes, :latitude_decimal_minutes, :latitude_decimal_minutes_width
5
+ t.string :latitude_hemisphere
6
+ t.integer :longitude_degrees, :longitude_minutes, :longitude_decimal_minutes, :longitude_decimal_minutes_width
7
+ t.string :longitude_hemisphere
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ require 'rubygems'
2
+
3
+ require 'test/unit'
4
+ require 'shoulda'
5
+
6
+ require 'active_record'
7
+ require 'action_view'
8
+ require 'active_support'
9
+ require 'active_support/test_case'
10
+
11
+ require 'lib/geo_tools'
12
+
13
+ ActiveRecord::Base.establish_connection(
14
+ :adapter => "sqlite3",
15
+ :database => ":memory:"
16
+ )
17
+ load File.dirname(__FILE__) + '/schema.rb'
18
+
19
+ class ActiveSupport::TestCase
20
+ # FIXME: why won't this work?
21
+ #self.use_transactional_fixtures = true
22
+ end
data/uninstall.rb ADDED
@@ -0,0 +1 @@
1
+ # Uninstall hook code here
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: geo_tools
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Andy Stewart
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-03-16 00:00:00 +00:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description:
17
+ email: boss@airbladesoftware.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README.md
24
+ files:
25
+ - MIT-LICENSE
26
+ - README.md
27
+ - Rakefile
28
+ - VERSION
29
+ - init.rb
30
+ - install.rb
31
+ - lib/air_blade/geo_tools/form_helpers.rb
32
+ - lib/air_blade/geo_tools/location.rb
33
+ - lib/air_blade/geo_tools/validations.rb
34
+ - lib/geo_tools.rb
35
+ - tasks/geo_tools_tasks.rake
36
+ - test/geo_tools_test.rb
37
+ - test/schema.rb
38
+ - test/test_helper.rb
39
+ - uninstall.rb
40
+ has_rdoc: true
41
+ homepage: http://github.com/airblade/geo_tools
42
+ licenses: []
43
+
44
+ post_install_message:
45
+ rdoc_options:
46
+ - --charset=UTF-8
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: "0"
54
+ version:
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: "0"
60
+ version:
61
+ requirements: []
62
+
63
+ rubyforge_project:
64
+ rubygems_version: 1.3.5
65
+ signing_key:
66
+ specification_version: 3
67
+ summary: View helpers, validations, and named scopes for locations.
68
+ test_files:
69
+ - test/geo_tools_test.rb
70
+ - test/schema.rb
71
+ - test/test_helper.rb