geo_tools 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
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