mappable.rb
16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
require 'geo_kit/defaults'
module GeoKit
# Contains class and instance methods providing distance calcuation services. This
# module is meant to be mixed into classes containing lat and lng attributes where
# distance calculation is desired.
#
# At present, two forms of distance calculations are provided:
#
# * Pythagorean Theory (flat Earth) - which assumes the world is flat and loses accuracy over long distances.
# * Haversine (sphere) - which is fairly accurate, but at a performance cost.
#
# Distance units supported are :miles and :kms.
module Mappable
PI_DIV_RAD = 0.0174
KMS_PER_MILE = 1.609
EARTH_RADIUS_IN_MILES = 3963.19
EARTH_RADIUS_IN_KMS = EARTH_RADIUS_IN_MILES * KMS_PER_MILE
MILES_PER_LATITUDE_DEGREE = 69.1
KMS_PER_LATITUDE_DEGREE = MILES_PER_LATITUDE_DEGREE * KMS_PER_MILE
LATITUDE_DEGREES = EARTH_RADIUS_IN_MILES / MILES_PER_LATITUDE_DEGREE
# Mix below class methods into the includer.
def self.included(receiver) # :nodoc:
receiver.extend ClassMethods
end
module ClassMethods #:nodoc:
# Returns the distance between two points. The from and to parameters are
# required to have lat and lng attributes. Valid options are:
# :units - valid values are :miles or :kms (GeoKit::default_units is the default)
# :formula - valid values are :flat or :sphere (GeoKit::default_formula is the default)
def distance_between(from, to, options={})
from=GeoKit::LatLng.normalize(from)
to=GeoKit::LatLng.normalize(to)
return 0.0 if from == to # fixes a "zero-distance" bug
units = options[:units] || GeoKit::default_units
formula = options[:formula] || GeoKit::default_formula
case formula
when :sphere
units_sphere_multiplier(units) *
Math.acos( Math.sin(deg2rad(from.lat)) * Math.sin(deg2rad(to.lat)) +
Math.cos(deg2rad(from.lat)) * Math.cos(deg2rad(to.lat)) *
Math.cos(deg2rad(to.lng) - deg2rad(from.lng)))
when :flat
Math.sqrt((units_per_latitude_degree(units)*(from.lat-to.lat))**2 +
(units_per_longitude_degree(from.lat, units)*(from.lng-to.lng))**2)
end
end
# Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
# from the first point to the second point. Typicaly, the instance methods will be used
# instead of this method.
def heading_between(from,to)
from=GeoKit::LatLng.normalize(from)
to=GeoKit::LatLng.normalize(to)
d_lng=deg2rad(to.lng-from.lng)
from_lat=deg2rad(from.lat)
to_lat=deg2rad(to.lat)
y=Math.sin(d_lng) * Math.cos(to_lat)
x=Math.cos(from_lat)*Math.sin(to_lat)-Math.sin(from_lat)*Math.cos(to_lat)*Math.cos(d_lng)
heading=to_heading(Math.atan2(y,x))
end
# Given a start point, distance, and heading (in degrees), provides
# an endpoint. Returns a LatLng instance. Typically, the instance method
# will be used instead of this method.
def endpoint(start,heading, distance, options={})
units = options[:units] || GeoKit::default_units
radius = units == :miles ? EARTH_RADIUS_IN_MILES : EARTH_RADIUS_IN_KMS
start=GeoKit::LatLng.normalize(start)
lat=deg2rad(start.lat)
lng=deg2rad(start.lng)
heading=deg2rad(heading)
distance=distance.to_f
end_lat=Math.asin(Math.sin(lat)*Math.cos(distance/radius) +
Math.cos(lat)*Math.sin(distance/radius)*Math.cos(heading))
end_lng=lng+Math.atan2(Math.sin(heading)*Math.sin(distance/radius)*Math.cos(lat),
Math.cos(distance/radius)-Math.sin(lat)*Math.sin(end_lat))
LatLng.new(rad2deg(end_lat),rad2deg(end_lng))
end
# Returns the midpoint, given two points. Returns a LatLng.
# Typically, the instance method will be used instead of this method.
# Valid option:
# :units - valid values are :miles or :kms (:miles is the default)
def midpoint_between(from,to,options={})
from=GeoKit::LatLng.normalize(from)
units = options[:units] || GeoKit::default_units
heading=from.heading_to(to)
distance=from.distance_to(to,options)
midpoint=from.endpoint(heading,distance/2,options)
end
# Geocodes a location using the multi geocoder.
def geocode(location)
res = Geocoders::MultiGeocoder.geocode(location)
return res if res.success
raise GeoKit::Geocoders::GeocodeError
end
protected
def deg2rad(degrees)
degrees.to_f / 180.0 * Math::PI
end
def rad2deg(rad)
rad.to_f * 180.0 / Math::PI
end
def to_heading(rad)
(rad2deg(rad)+360)%360
end
# Returns the multiplier used to obtain the correct distance units.
def units_sphere_multiplier(units)
units == :miles ? EARTH_RADIUS_IN_MILES : EARTH_RADIUS_IN_KMS
end
# Returns the number of units per latitude degree.
def units_per_latitude_degree(units)
units == :miles ? MILES_PER_LATITUDE_DEGREE : KMS_PER_LATITUDE_DEGREE
end
# Returns the number units per longitude degree.
def units_per_longitude_degree(lat, units)
miles_per_longitude_degree = (LATITUDE_DEGREES * Math.cos(lat * PI_DIV_RAD)).abs
units == :miles ? miles_per_longitude_degree : miles_per_longitude_degree * KMS_PER_MILE
end
end
# -----------------------------------------------------------------------------------------------
# Instance methods below here
# -----------------------------------------------------------------------------------------------
# Extracts a LatLng instance. Use with models that are acts_as_mappable
def to_lat_lng
return self if instance_of?(GeoKit::LatLng) || instance_of?(GeoKit::GeoLoc)
return LatLng.new(send(self.class.lat_column_name),send(self.class.lng_column_name)) if self.class.respond_to?(:acts_as_mappable)
return nil
end
# Returns the distance from another point. The other point parameter is
# required to have lat and lng attributes. Valid options are:
# :units - valid values are :miles or :kms (:miles is the default)
# :formula - valid values are :flat or :sphere (:sphere is the default)
def distance_to(other, options={})
self.class.distance_between(self, other, options)
end
alias distance_from distance_to
# Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
# to the given point. The given point can be a LatLng or a string to be Geocoded
def heading_to(other)
self.class.heading_between(self,other)
end
# Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
# FROM the given point. The given point can be a LatLng or a string to be Geocoded
def heading_from(other)
self.class.heading_between(other,self)
end
# Returns the endpoint, given a heading (in degrees) and distance.
# Valid option:
# :units - valid values are :miles or :kms (:miles is the default)
def endpoint(heading,distance,options={})
self.class.endpoint(self,heading,distance,options)
end
# Returns the midpoint, given another point on the map.
# Valid option:
# :units - valid values are :miles or :kms (:miles is the default)
def midpoint_to(other, options={})
self.class.midpoint_between(self,other,options)
end
end
class LatLng
include Mappable
attr_accessor :lat, :lng
# Accepts latitude and longitude or instantiates an empty instance
# if lat and lng are not provided. Converted to floats if provided
def initialize(lat=nil, lng=nil)
lat = lat.to_f if lat && !lat.is_a?(Numeric)
lng = lng.to_f if lng && !lng.is_a?(Numeric)
@lat = lat
@lng = lng
end
# Latitude attribute setter; stored as a float.
def lat=(lat)
@lat = lat.to_f if lat
end
# Longitude attribute setter; stored as a float;
def lng=(lng)
@lng=lng.to_f if lng
end
# Returns the lat and lng attributes as a comma-separated string.
def ll
"#{lat},#{lng}"
end
#returns a string with comma-separated lat,lng values
def to_s
ll
end
#returns a two-element array
def to_a
[lat,lng]
end
# Returns true if the candidate object is logically equal. Logical equivalence
# is true if the lat and lng attributes are the same for both objects.
def ==(other)
other.is_a?(LatLng) ? self.lat == other.lat && self.lng == other.lng : false
end
# A *class* method to take anything which can be inferred as a point and generate
# a LatLng from it. You should use this anything you're not sure what the input is,
# and want to deal with it as a LatLng if at all possible. Can take:
# 1) two arguments (lat,lng)
# 2) a string in the format "37.1234,-129.1234" or "37.1234 -129.1234"
# 3) a string which can be geocoded on the fly
# 4) an array in the format [37.1234,-129.1234]
# 5) a LatLng or GeoLoc (which is just passed through as-is)
# 6) anything which acts_as_mappable -- a LatLng will be extracted from it
def self.normalize(thing,other=nil)
# if an 'other' thing is supplied, normalize the input by creating an array of two elements
thing=[thing,other] if other
if thing.is_a?(String)
thing.strip!
if match=thing.match(/(\-?\d+\.?\d*)[, ] ?(\-?\d+\.?\d*)$/)
return GeoKit::LatLng.new(match[1],match[2])
else
res = GeoKit::Geocoders::MultiGeocoder.geocode(thing)
return res if res.success
raise GeoKit::Geocoders::GeocodeError
end
elsif thing.is_a?(Array) && thing.size==2
return GeoKit::LatLng.new(thing[0],thing[1])
elsif thing.is_a?(LatLng) # will also be true for GeoLocs
return thing
elsif thing.class.respond_to?(:acts_as_mappable) && thing.class.respond_to?(:distance_column_name)
return thing.to_lat_lng
end
throw ArgumentError.new("#{thing} (#{thing.class}) cannot be normalized to a LatLng. We tried interpreting it as an array, string, Mappable, etc., but no dice.")
end
end
# This class encapsulates the result of a geocoding call
# It's primary purpose is to homogenize the results of multiple
# geocoding providers. It also provides some additional functionality, such as
# the "full address" method for geocoders that do not provide a
# full address in their results (for example, Yahoo), and the "is_us" method.
class GeoLoc < LatLng
# Location attributes. Full address is a concatenation of all values. For example:
# 100 Spear St, San Francisco, CA, 94101, US
attr_accessor :street_address, :city, :state, :zip, :country_code, :full_address
# Attributes set upon return from geocoding. Success will be true for successful
# geocode lookups. The provider will be set to the name of the providing geocoder.
# Finally, precision is an indicator of the accuracy of the geocoding.
attr_accessor :success, :provider, :precision
# Street number and street name are extracted from the street address attribute.
attr_reader :street_number, :street_name
# Constructor expects a hash of symbols to correspond with attributes.
def initialize(h={})
@street_address=h[:street_address]
@city=h[:city]
@state=h[:state]
@zip=h[:zip]
@country_code=h[:country_code]
@success=false
@precision='unknown'
super(h[:lat],h[:lng])
end
# Returns true if geocoded to the United States.
def is_us?
country_code == 'US'
end
# full_address is provided by google but not by yahoo. It is intended that the google
# geocoding method will provide the full address, whereas for yahoo it will be derived
# from the parts of the address we do have.
def full_address
@full_address ? @full_address : to_geocodeable_s
end
# Extracts the street number from the street address if the street address
# has a value.
def street_number
street_address[/(\d*)/] if street_address
end
# Returns the street name portion of the street address.
def street_name
street_address[street_number.length, street_address.length].strip if street_address
end
# gives you all the important fields as key-value pairs
def hash
res={}
[:success,:lat,:lng,:country_code,:city,:state,:zip,:street_address,:provider,:full_address,:is_us?,:ll,:precision].each { |s| res[s] = self.send(s.to_s) }
res
end
alias to_hash hash
# Sets the city after capitalizing each word within the city name.
def city=(city)
@city = city.titleize if city
end
# Sets the street address after capitalizing each word within the street address.
def street_address=(address)
@street_address = address.titleize if address
end
# Returns a comma-delimited string consisting of the street address, city, state,
# zip, and country code. Only includes those attributes that are non-blank.
def to_geocodeable_s
a=[street_address, city, state, zip, country_code].compact
a.delete_if { |e| !e || e == '' }
a.join(', ')
end
# Returns a string representation of the instance.
def to_s
"Provider: #{provider}\n Street: #{street_address}\nCity: #{city}\nState: #{state}\nZip: #{zip}\nLatitude: #{lat}\nLongitude: #{lng}\nCountry: #{country_code}\nSuccess: #{success}"
end
end
# Bounds represents a rectangular bounds, defined by the SW and NE corners
class Bounds
# sw and ne are LatLng objects
attr_accessor :sw, :ne
# provide sw and ne to instantiate a new Bounds instance
def initialize(sw,ne)
raise ArguementError if !(sw.is_a?(GeoKit::LatLng) && ne.is_a?(GeoKit::LatLng))
@sw,@ne=sw,ne
end
#returns the a single point which is the center of the rectangular bounds
def center
@sw.midpoint_to(@ne)
end
# a simple string representation:sw,ne
def to_s
"#{@sw.to_s},#{@ne.to_s}"
end
# a two-element array of two-element arrays: sw,ne
def to_a
[@sw.to_a, @ne.to_a]
end
# Returns true if the bounds contain the passed point.
# allows for bounds which cross the meridian
def contains?(point)
point=GeoKit::LatLng.normalize(point)
res = point.lat > @sw.lat && point.lat < @ne.lat
if crosses_meridian?
res &= point.lng < @ne.lng || point.lng > @sw.lng
else
res &= point.lng < @ne.lng && point.lng > @sw.lng
end
res
end
# returns true if the bounds crosses the international dateline
def crosses_meridian?
@sw.lng > @ne.lng
end
# Returns true if the candidate object is logically equal. Logical equivalence
# is true if the lat and lng attributes are the same for both objects.
def ==(other)
other.is_a?(Bounds) ? self.sw == other.sw && self.ne == other.ne : false
end
class <<self
# returns an instance of bounds which completely encompases the given circle
def from_point_and_radius(point,radius,options={})
point=LatLng.normalize(point)
p0=point.endpoint(0,radius,options)
p90=point.endpoint(90,radius,options)
p180=point.endpoint(180,radius,options)
p270=point.endpoint(270,radius,options)
sw=GeoKit::LatLng.new(p180.lat,p270.lng)
ne=GeoKit::LatLng.new(p0.lat,p90.lng)
GeoKit::Bounds.new(sw,ne)
end
# Takes two main combinations of arguements to create a bounds:
# point,point (this is the only one which takes two arguments
# [point,point]
# . . . where a point is anything LatLng#normalize can handle (which is quite a lot)
#
# NOTE: everything combination is assumed to pass points in the order sw, ne
def normalize (thing,other=nil)
# maybe this will be simple -- an actual bounds object is passed, and we can all go home
return thing if thing.is_a? Bounds
# no? OK, if there's no "other," the thing better be a two-element array
thing,other=thing if !other && thing.is_a?(Array) && thing.size==2
# Now that we're set with a thing and another thing, let LatLng do the heavy lifting.
# Exceptions may be thrown
Bounds.new(GeoKit::LatLng.normalize(thing),GeoKit::LatLng.normalize(other))
end
end
end
end