radiant-location-extension 1.2.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/.gitignore +17 -0
- data/Gemfile +4 -0
- data/README +74 -0
- data/Rakefile +15 -0
- data/app/.DS_Store +0 -0
- data/app/controllers/.DS_Store +0 -0
- data/app/controllers/admin/locations_controller.rb +3 -0
- data/app/helpers/.DS_Store +0 -0
- data/app/helpers/admin/location_helper.rb +2 -0
- data/app/models/location.rb +24 -0
- data/app/models/location_finder_page.rb +105 -0
- data/app/models/locations_tags.rb +109 -0
- data/app/views/.DS_Store +0 -0
- data/app/views/admin/.DS_Store +0 -0
- data/app/views/admin/locations/_location.html.haml +11 -0
- data/app/views/admin/locations/edit.html.haml +59 -0
- data/app/views/admin/locations/index.html.haml +26 -0
- data/app/views/admin/locations/new.html.haml +75 -0
- data/app/views/admin/locations/remove.html.haml +20 -0
- data/config/routes.rb +5 -0
- data/db/migrate/001_create_locations.rb +15 -0
- data/db/migrate/002_set_properties.rb +31 -0
- data/lib/geo_kit/acts_as_mappable.rb +436 -0
- data/lib/geo_kit/geocoders.rb +351 -0
- data/lib/geo_kit/ip_geocode_lookup.rb +46 -0
- data/lib/geo_kit/mappable.rb +430 -0
- data/lib/location_geo_kit.rb +39 -0
- data/lib/radiant-location-extension.rb +5 -0
- data/lib/radiant-location-extension/version.rb +3 -0
- data/lib/tasks/location_extension_tasks.rake +28 -0
- data/location_extension.rb +51 -0
- data/public/images/admin/location.png +0 -0
- data/public/stylesheets/admin/location.css +19 -0
- data/radiant-location-extension.gemspec +21 -0
- metadata +102 -0
@@ -0,0 +1,351 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
require 'rexml/document'
|
3
|
+
require 'yaml'
|
4
|
+
require 'timeout'
|
5
|
+
|
6
|
+
module GeoKit
|
7
|
+
# Contains a set of geocoders which can be used independently if desired. The list contains:
|
8
|
+
#
|
9
|
+
# * Google Geocoder - requires an API key.
|
10
|
+
# * Yahoo Geocoder - requires an API key.
|
11
|
+
# * Geocoder.us - may require authentication if performing more than the free request limit.
|
12
|
+
# * Geocoder.ca - for Canada; may require authentication as well.
|
13
|
+
# * IP Geocoder - geocodes an IP address using hostip.info's web service.
|
14
|
+
# * Multi Geocoder - provides failover for the physical location geocoders.
|
15
|
+
#
|
16
|
+
# Some configuration is required for these geocoders and can be located in the environment
|
17
|
+
# configuration files.
|
18
|
+
module Geocoders
|
19
|
+
@@proxy_addr = nil
|
20
|
+
@@proxy_port = nil
|
21
|
+
@@proxy_user = nil
|
22
|
+
@@proxy_pass = nil
|
23
|
+
@@timeout = nil
|
24
|
+
@@yahoo = 'REPLACE_WITH_YOUR_YAHOO_KEY'
|
25
|
+
@@google = 'REPLACE_WITH_YOUR_GOOGLE_KEY'
|
26
|
+
@@geocoder_us = false
|
27
|
+
@@geocoder_ca = false
|
28
|
+
@@provider_order = [:google,:us]
|
29
|
+
|
30
|
+
[:yahoo, :google, :geocoder_us, :geocoder_ca, :provider_order, :timeout,
|
31
|
+
:proxy_addr, :proxy_port, :proxy_user, :proxy_pass].each do |sym|
|
32
|
+
class_eval <<-EOS, __FILE__, __LINE__
|
33
|
+
def self.#{sym}
|
34
|
+
if defined?(#{sym.to_s.upcase})
|
35
|
+
#{sym.to_s.upcase}
|
36
|
+
else
|
37
|
+
@@#{sym}
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.#{sym}=(obj)
|
42
|
+
@@#{sym} = obj
|
43
|
+
end
|
44
|
+
EOS
|
45
|
+
end
|
46
|
+
|
47
|
+
# Error which is thrown in the event a geocoding error occurs.
|
48
|
+
class GeocodeError < StandardError; end
|
49
|
+
|
50
|
+
# The Geocoder base class which defines the interface to be used by all
|
51
|
+
# other geocoders.
|
52
|
+
class Geocoder
|
53
|
+
# Main method which calls the do_geocode template method which subclasses
|
54
|
+
# are responsible for implementing. Returns a populated GeoLoc or an
|
55
|
+
# empty one with a failed success code.
|
56
|
+
def self.geocode(address)
|
57
|
+
res = do_geocode(address)
|
58
|
+
return res.success ? res : GeoLoc.new
|
59
|
+
end
|
60
|
+
|
61
|
+
# Call the geocoder service using the timeout if configured.
|
62
|
+
def self.call_geocoder_service(url)
|
63
|
+
timeout(GeoKit::Geocoders::timeout) { return self.do_get(url) } if GeoKit::Geocoders::timeout
|
64
|
+
return self.do_get(url)
|
65
|
+
rescue TimeoutError
|
66
|
+
return nil
|
67
|
+
end
|
68
|
+
|
69
|
+
protected
|
70
|
+
|
71
|
+
def self.logger() RAILS_DEFAULT_LOGGER; end
|
72
|
+
|
73
|
+
private
|
74
|
+
|
75
|
+
# Wraps the geocoder call around a proxy if necessary.
|
76
|
+
def self.do_get(url)
|
77
|
+
return Net::HTTP::Proxy(GeoKit::Geocoders::proxy_addr, GeoKit::Geocoders::proxy_port,
|
78
|
+
GeoKit::Geocoders::proxy_user, GeoKit::Geocoders::proxy_pass).get_response(URI.parse(url))
|
79
|
+
end
|
80
|
+
|
81
|
+
# Adds subclass' geocode method making it conveniently available through
|
82
|
+
# the base class.
|
83
|
+
def self.inherited(clazz)
|
84
|
+
class_name = clazz.name.split('::').last
|
85
|
+
src = <<-END_SRC
|
86
|
+
def self.#{class_name.underscore}(address)
|
87
|
+
#{class_name}.geocode(address)
|
88
|
+
end
|
89
|
+
END_SRC
|
90
|
+
class_eval(src)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
# Geocoder CA geocoder implementation. Requires the GeoKit::Geocoders::GEOCODER_CA variable to
|
95
|
+
# contain true or false based upon whether authentication is to occur. Conforms to the
|
96
|
+
# interface set by the Geocoder class.
|
97
|
+
#
|
98
|
+
# Returns a response like:
|
99
|
+
# <?xml version="1.0" encoding="UTF-8" ?>
|
100
|
+
# <geodata>
|
101
|
+
# <latt>49.243086</latt>
|
102
|
+
# <longt>-123.153684</longt>
|
103
|
+
# </geodata>
|
104
|
+
class CaGeocoder < Geocoder
|
105
|
+
|
106
|
+
private
|
107
|
+
|
108
|
+
# Template method which does the geocode lookup.
|
109
|
+
def self.do_geocode(address)
|
110
|
+
raise ArgumentError('Geocoder.ca requires a GeoLoc argument') unless address.is_a?(GeoLoc)
|
111
|
+
url = construct_request(address)
|
112
|
+
res = self.call_geocoder_service(url)
|
113
|
+
return GeoLoc.new if !res.is_a?(Net::HTTPSuccess)
|
114
|
+
xml = res.body
|
115
|
+
logger.debug "Geocoder.ca geocoding. Address: #{address}. Result: #{xml}"
|
116
|
+
# Parse the document.
|
117
|
+
doc = REXML::Document.new(xml)
|
118
|
+
address.lat = doc.elements['//latt'].text
|
119
|
+
address.lng = doc.elements['//longt'].text
|
120
|
+
address.success = true
|
121
|
+
return address
|
122
|
+
rescue
|
123
|
+
logger.error "Caught an error during Geocoder.ca geocoding call: "+$!
|
124
|
+
return GeoLoc.new
|
125
|
+
end
|
126
|
+
|
127
|
+
# Formats the request in the format acceptable by the CA geocoder.
|
128
|
+
def self.construct_request(location)
|
129
|
+
url = ""
|
130
|
+
url += add_ampersand(url) + "stno=#{location.street_number}" if location.street_address
|
131
|
+
url += add_ampersand(url) + "addresst=#{CGI.escape(location.street_name)}" if location.street_address
|
132
|
+
url += add_ampersand(url) + "city=#{CGI.escape(location.city)}" if location.city
|
133
|
+
url += add_ampersand(url) + "prov=#{location.state}" if location.state
|
134
|
+
url += add_ampersand(url) + "postal=#{location.zip}" if location.zip
|
135
|
+
url += add_ampersand(url) + "auth=#{GeoKit::Geocoders::geocoder_ca}" if GeoKit::Geocoders::geocoder_ca
|
136
|
+
url += add_ampersand(url) + "geoit=xml"
|
137
|
+
'http://geocoder.ca/?' + url
|
138
|
+
end
|
139
|
+
|
140
|
+
def self.add_ampersand(url)
|
141
|
+
url && url.length > 0 ? "&" : ""
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Google geocoder implementation. Requires the GeoKit::Geocoders::GOOGLE variable to
|
146
|
+
# contain a Google API key. Conforms to the interface set by the Geocoder class.
|
147
|
+
class GoogleGeocoder < Geocoder
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# Template method which does the geocode lookup.
|
152
|
+
def self.do_geocode(address)
|
153
|
+
address_str = address.is_a?(GeoLoc) ? address.to_geocodeable_s : address
|
154
|
+
res = self.call_geocoder_service("http://maps.google.com/maps/geo?q=#{CGI.escape(address_str)}&output=xml&key=#{GeoKit::Geocoders::google}&oe=utf-8")
|
155
|
+
# res = Net::HTTP.get_response(URI.parse("http://maps.google.com/maps/geo?q=#{CGI.escape(address_str)}&output=xml&key=#{GeoKit::Geocoders::google}&oe=utf-8"))
|
156
|
+
|
157
|
+
|
158
|
+
|
159
|
+
return GeoLoc.new if !res.is_a?(Net::HTTPSuccess)
|
160
|
+
xml=res.body
|
161
|
+
logger.debug "Google geocoding. Address: #{address}. Result: #{xml}"
|
162
|
+
doc=REXML::Document.new(xml)
|
163
|
+
if doc.elements['//kml/Response/Status/code'].text == '200'
|
164
|
+
|
165
|
+
res = GeoLoc.new
|
166
|
+
coordinates=doc.elements['//coordinates'].text.to_s.split(',')
|
167
|
+
|
168
|
+
#basics
|
169
|
+
res.lat=coordinates[1]
|
170
|
+
res.lng=coordinates[0]
|
171
|
+
res.country_code=doc.elements['//CountryNameCode'].text
|
172
|
+
res.provider='google'
|
173
|
+
|
174
|
+
#extended -- false if not not available
|
175
|
+
res.city = doc.elements['//LocalityName'].text if doc.elements['//LocalityName']
|
176
|
+
res.state = doc.elements['//AdministrativeAreaName'].text if doc.elements['//AdministrativeAreaName']
|
177
|
+
res.full_address = doc.elements['//address'].text if doc.elements['//address'] # google provides it
|
178
|
+
res.zip = doc.elements['//PostalCodeNumber'].text if doc.elements['//PostalCodeNumber']
|
179
|
+
res.street_address = doc.elements['//ThoroughfareName'].text if doc.elements['//ThoroughfareName']
|
180
|
+
# Translate accuracy into Yahoo-style token address, street, zip, zip+4, city, state, country
|
181
|
+
# For Google, 1=low accuracy, 8=high accuracy
|
182
|
+
# old way -- address_details=doc.elements['//AddressDetails','urn:oasis:names:tc:ciq:xsdschema:xAL:2.0']
|
183
|
+
address_details=doc.elements['//*[local-name() = "AddressDetails"]']
|
184
|
+
accuracy = address_details ? address_details.attributes['Accuracy'].to_i : 0
|
185
|
+
res.precision=%w{unknown country state state city zip zip+4 street address}[accuracy]
|
186
|
+
res.success=true
|
187
|
+
|
188
|
+
return res
|
189
|
+
else
|
190
|
+
logger.info "Google was unable to geocode address: "+address
|
191
|
+
return GeoLoc.new
|
192
|
+
end
|
193
|
+
|
194
|
+
rescue
|
195
|
+
logger.error "Caught an error during Google geocoding call: "+$!
|
196
|
+
return GeoLoc.new
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
# Provides geocoding based upon an IP address. The underlying web service is a hostip.info
|
201
|
+
# which sources their data through a combination of publicly available information as well
|
202
|
+
# as community contributions.
|
203
|
+
class IpGeocoder < Geocoder
|
204
|
+
|
205
|
+
private
|
206
|
+
|
207
|
+
# Given an IP address, returns a GeoLoc instance which contains latitude,
|
208
|
+
# longitude, city, and country code. Sets the success attribute to false if the ip
|
209
|
+
# parameter does not match an ip address.
|
210
|
+
def self.do_geocode(ip)
|
211
|
+
return GeoLoc.new unless /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})?$/.match(ip)
|
212
|
+
url = "http://api.hostip.info/get_html.php?ip=#{ip}&position=true"
|
213
|
+
response = self.call_geocoder_service(url)
|
214
|
+
response.is_a?(Net::HTTPSuccess) ? parse_body(response.body) : GeoLoc.new
|
215
|
+
rescue
|
216
|
+
logger.error "Caught an error during HostIp geocoding call: "+$!
|
217
|
+
return GeoLoc.new
|
218
|
+
end
|
219
|
+
|
220
|
+
# Converts the body to YAML since its in the form of:
|
221
|
+
#
|
222
|
+
# Country: UNITED STATES (US)
|
223
|
+
# City: Sugar Grove, IL
|
224
|
+
# Latitude: 41.7696
|
225
|
+
# Longitude: -88.4588
|
226
|
+
#
|
227
|
+
# then instantiates a GeoLoc instance to populate with location data.
|
228
|
+
def self.parse_body(body) # :nodoc:
|
229
|
+
yaml = YAML.load(body)
|
230
|
+
res = GeoLoc.new
|
231
|
+
res.provider = 'hostip'
|
232
|
+
res.city, res.state = yaml['City'].split(', ')
|
233
|
+
country, res.country_code = yaml['Country'].split(' (')
|
234
|
+
res.lat = yaml['Latitude']
|
235
|
+
res.lng = yaml['Longitude']
|
236
|
+
res.country_code.chop!
|
237
|
+
res.success = res.city != "(Private Address)"
|
238
|
+
res
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# Geocoder Us geocoder implementation. Requires the GeoKit::Geocoders::GEOCODER_US variable to
|
243
|
+
# contain true or false based upon whether authentication is to occur. Conforms to the
|
244
|
+
# interface set by the Geocoder class.
|
245
|
+
class UsGeocoder < Geocoder
|
246
|
+
|
247
|
+
private
|
248
|
+
|
249
|
+
# For now, the geocoder_method will only geocode full addresses -- not zips or cities in isolation
|
250
|
+
def self.do_geocode(address)
|
251
|
+
address_str = address.is_a?(GeoLoc) ? address.to_geocodeable_s : address
|
252
|
+
url = "http://"+(GeoKit::Geocoders::geocoder_us || '')+"geocoder.us/service/csv/geocode?address=#{CGI.escape(address_str)}"
|
253
|
+
res = self.call_geocoder_service(url)
|
254
|
+
return GeoLoc.new if !res.is_a?(Net::HTTPSuccess)
|
255
|
+
data = res.body
|
256
|
+
logger.debug "Geocoder.us geocoding. Address: #{address}. Result: #{data}"
|
257
|
+
array = data.chomp.split(',')
|
258
|
+
|
259
|
+
if array.length == 6
|
260
|
+
res=GeoLoc.new
|
261
|
+
res.lat,res.lng,res.street_address,res.city,res.state,res.zip=array
|
262
|
+
res.country_code='US'
|
263
|
+
res.success=true
|
264
|
+
return res
|
265
|
+
else
|
266
|
+
logger.info "geocoder.us was unable to geocode address: "+address
|
267
|
+
return GeoLoc.new
|
268
|
+
end
|
269
|
+
rescue
|
270
|
+
logger.error "Caught an error during geocoder.us geocoding call: "+$!
|
271
|
+
return GeoLoc.new
|
272
|
+
end
|
273
|
+
end
|
274
|
+
|
275
|
+
# Yahoo geocoder implementation. Requires the GeoKit::Geocoders::YAHOO variable to
|
276
|
+
# contain a Yahoo API key. Conforms to the interface set by the Geocoder class.
|
277
|
+
class YahooGeocoder < Geocoder
|
278
|
+
|
279
|
+
private
|
280
|
+
|
281
|
+
# Template method which does the geocode lookup.
|
282
|
+
def self.do_geocode(address)
|
283
|
+
address_str = address.is_a?(GeoLoc) ? address.to_geocodeable_s : address
|
284
|
+
url="http://api.local.yahoo.com/MapsService/V1/geocode?appid=#{GeoKit::Geocoders::yahoo}&location=#{CGI.escape(address_str)}"
|
285
|
+
res = self.call_geocoder_service(url)
|
286
|
+
return GeoLoc.new if !res.is_a?(Net::HTTPSuccess)
|
287
|
+
xml = res.body
|
288
|
+
doc = REXML::Document.new(xml)
|
289
|
+
logger.debug "Yahoo geocoding. Address: #{address}. Result: #{xml}"
|
290
|
+
|
291
|
+
if doc.elements['//ResultSet']
|
292
|
+
res=GeoLoc.new
|
293
|
+
|
294
|
+
#basic
|
295
|
+
res.lat=doc.elements['//Latitude'].text
|
296
|
+
res.lng=doc.elements['//Longitude'].text
|
297
|
+
res.country_code=doc.elements['//Country'].text
|
298
|
+
res.provider='yahoo'
|
299
|
+
|
300
|
+
#extended - false if not available
|
301
|
+
res.city=doc.elements['//City'].text if doc.elements['//City'] && doc.elements['//City'].text != nil
|
302
|
+
res.state=doc.elements['//State'].text if doc.elements['//State'] && doc.elements['//State'].text != nil
|
303
|
+
res.zip=doc.elements['//Zip'].text if doc.elements['//Zip'] && doc.elements['//Zip'].text != nil
|
304
|
+
res.street_address=doc.elements['//Address'].text if doc.elements['//Address'] && doc.elements['//Address'].text != nil
|
305
|
+
res.precision=doc.elements['//Result'].attributes['precision'] if doc.elements['//Result']
|
306
|
+
res.success=true
|
307
|
+
return res
|
308
|
+
else
|
309
|
+
logger.info "Yahoo was unable to geocode address: "+address
|
310
|
+
return GeoLoc.new
|
311
|
+
end
|
312
|
+
|
313
|
+
rescue
|
314
|
+
logger.info "Caught an error during Yahoo geocoding call: "+$!
|
315
|
+
return GeoLoc.new
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
# Provides methods to geocode with a variety of geocoding service providers, plus failover
|
320
|
+
# among providers in the order you configure.
|
321
|
+
#
|
322
|
+
# Goal:
|
323
|
+
# - homogenize the results of multiple geocoders
|
324
|
+
#
|
325
|
+
# Limitations:
|
326
|
+
# - currently only provides the first result. Sometimes geocoders will return multiple results.
|
327
|
+
# - currently discards the "accuracy" component of the geocoding calls
|
328
|
+
class MultiGeocoder < Geocoder
|
329
|
+
private
|
330
|
+
|
331
|
+
# This method will call one or more geocoders in the order specified in the
|
332
|
+
# configuration until one of the geocoders work.
|
333
|
+
#
|
334
|
+
# The failover approach is crucial for production-grade apps, but is rarely used.
|
335
|
+
# 98% of your geocoding calls will be successful with the first call
|
336
|
+
def self.do_geocode(address)
|
337
|
+
GeoKit::Geocoders::provider_order.each do |provider|
|
338
|
+
begin
|
339
|
+
klass = GeoKit::Geocoders.const_get "#{provider.to_s.capitalize}Geocoder"
|
340
|
+
res = klass.send :geocode, address
|
341
|
+
return res if res.success
|
342
|
+
rescue
|
343
|
+
logger.error("Something has gone very wrong during geocoding, OR you have configured an invalid class name in GeoKit::Geocoders::provider_order. Address: #{address}. Provider: #{provider}")
|
344
|
+
end
|
345
|
+
end
|
346
|
+
# If we get here, we failed completely.
|
347
|
+
GeoLoc.new
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module GeoKit
|
4
|
+
# Contains a class method geocode_ip_address which can be used to enable automatic geocoding
|
5
|
+
# for request IP addresses. The geocoded information is stored in a cookie and in the
|
6
|
+
# session to minimize web service calls. The point of the helper is to enable location-based
|
7
|
+
# websites to have a best-guess for new visitors.
|
8
|
+
module IpGeocodeLookup
|
9
|
+
# Mix below class methods into ActionController.
|
10
|
+
def self.included(base) # :nodoc:
|
11
|
+
base.extend ClassMethods
|
12
|
+
end
|
13
|
+
|
14
|
+
# Class method to mix into active record.
|
15
|
+
module ClassMethods # :nodoc:
|
16
|
+
def geocode_ip_address(filter_options = {})
|
17
|
+
before_filter :store_ip_location, filter_options
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# Places the IP address' geocode location into the session if it
|
24
|
+
# can be found. Otherwise, looks for a geo location cookie and
|
25
|
+
# uses that value. The last resort is to call the web service to
|
26
|
+
# get the value.
|
27
|
+
def store_ip_location
|
28
|
+
session[:geo_location] ||= retrieve_location_from_cookie_or_service
|
29
|
+
cookies[:geo_location] = { :value => session[:geo_location].to_yaml, :expires => 30.days.from_now } if session[:geo_location]
|
30
|
+
end
|
31
|
+
|
32
|
+
# Uses the stored location value from the cookie if it exists. If
|
33
|
+
# no cookie exists, calls out to the web service to get the location.
|
34
|
+
def retrieve_location_from_cookie_or_service
|
35
|
+
return YAML.load(cookies[:geo_location]) if cookies[:geo_location]
|
36
|
+
location = Geocoders::IpGeocoder.geocode(get_ip_address)
|
37
|
+
return location.success ? location : nil
|
38
|
+
end
|
39
|
+
|
40
|
+
# Returns the real ip address, though this could be the localhost ip
|
41
|
+
# address. No special handling here anymore.
|
42
|
+
def get_ip_address
|
43
|
+
request.remote_ip
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,430 @@
|
|
1
|
+
module GeoKit
|
2
|
+
# Contains class and instance methods providing distance calcuation services. This
|
3
|
+
# module is meant to be mixed into classes containing lat and lng attributes where
|
4
|
+
# distance calculation is desired.
|
5
|
+
#
|
6
|
+
# At present, two forms of distance calculations are provided:
|
7
|
+
#
|
8
|
+
# * Pythagorean Theory (flat Earth) - which assumes the world is flat and loses accuracy over long distances.
|
9
|
+
# * Haversine (sphere) - which is fairly accurate, but at a performance cost.
|
10
|
+
#
|
11
|
+
# Distance units supported are :miles and :kms.
|
12
|
+
module Mappable
|
13
|
+
PI_DIV_RAD = 0.0174
|
14
|
+
KMS_PER_MILE = 1.609
|
15
|
+
EARTH_RADIUS_IN_MILES = 3963.19
|
16
|
+
EARTH_RADIUS_IN_KMS = EARTH_RADIUS_IN_MILES * KMS_PER_MILE
|
17
|
+
MILES_PER_LATITUDE_DEGREE = 69.1
|
18
|
+
KMS_PER_LATITUDE_DEGREE = MILES_PER_LATITUDE_DEGREE * KMS_PER_MILE
|
19
|
+
LATITUDE_DEGREES = EARTH_RADIUS_IN_MILES / MILES_PER_LATITUDE_DEGREE
|
20
|
+
|
21
|
+
# Mix below class methods into the includer.
|
22
|
+
def self.included(receiver) # :nodoc:
|
23
|
+
receiver.extend ClassMethods
|
24
|
+
end
|
25
|
+
|
26
|
+
module ClassMethods #:nodoc:
|
27
|
+
# Returns the distance between two points. The from and to parameters are
|
28
|
+
# required to have lat and lng attributes. Valid options are:
|
29
|
+
# :units - valid values are :miles or :kms (GeoKit::default_units is the default)
|
30
|
+
# :formula - valid values are :flat or :sphere (GeoKit::default_formula is the default)
|
31
|
+
def distance_between(from, to, options={})
|
32
|
+
from=GeoKit::LatLng.normalize(from)
|
33
|
+
to=GeoKit::LatLng.normalize(to)
|
34
|
+
return 0.0 if from == to # fixes a "zero-distance" bug
|
35
|
+
units = options[:units] || GeoKit::default_units
|
36
|
+
formula = options[:formula] || GeoKit::default_formula
|
37
|
+
case formula
|
38
|
+
when :sphere
|
39
|
+
units_sphere_multiplier(units) *
|
40
|
+
Math.acos( Math.sin(deg2rad(from.lat)) * Math.sin(deg2rad(to.lat)) +
|
41
|
+
Math.cos(deg2rad(from.lat)) * Math.cos(deg2rad(to.lat)) *
|
42
|
+
Math.cos(deg2rad(to.lng) - deg2rad(from.lng)))
|
43
|
+
when :flat
|
44
|
+
Math.sqrt((units_per_latitude_degree(units)*(from.lat-to.lat))**2 +
|
45
|
+
(units_per_longitude_degree(from.lat, units)*(from.lng-to.lng))**2)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
|
50
|
+
# from the first point to the second point. Typicaly, the instance methods will be used
|
51
|
+
# instead of this method.
|
52
|
+
def heading_between(from,to)
|
53
|
+
from=GeoKit::LatLng.normalize(from)
|
54
|
+
to=GeoKit::LatLng.normalize(to)
|
55
|
+
|
56
|
+
d_lng=deg2rad(to.lng-from.lng)
|
57
|
+
from_lat=deg2rad(from.lat)
|
58
|
+
to_lat=deg2rad(to.lat)
|
59
|
+
y=Math.sin(d_lng) * Math.cos(to_lat)
|
60
|
+
x=Math.cos(from_lat)*Math.sin(to_lat)-Math.sin(from_lat)*Math.cos(to_lat)*Math.cos(d_lng)
|
61
|
+
heading=to_heading(Math.atan2(y,x))
|
62
|
+
end
|
63
|
+
|
64
|
+
# Given a start point, distance, and heading (in degrees), provides
|
65
|
+
# an endpoint. Returns a LatLng instance. Typically, the instance method
|
66
|
+
# will be used instead of this method.
|
67
|
+
def endpoint(start,heading, distance, options={})
|
68
|
+
units = options[:units] || GeoKit::default_units
|
69
|
+
radius = units == :miles ? EARTH_RADIUS_IN_MILES : EARTH_RADIUS_IN_KMS
|
70
|
+
start=GeoKit::LatLng.normalize(start)
|
71
|
+
lat=deg2rad(start.lat)
|
72
|
+
lng=deg2rad(start.lng)
|
73
|
+
heading=deg2rad(heading)
|
74
|
+
distance=distance.to_f
|
75
|
+
|
76
|
+
end_lat=Math.asin(Math.sin(lat)*Math.cos(distance/radius) +
|
77
|
+
Math.cos(lat)*Math.sin(distance/radius)*Math.cos(heading))
|
78
|
+
|
79
|
+
end_lng=lng+Math.atan2(Math.sin(heading)*Math.sin(distance/radius)*Math.cos(lat),
|
80
|
+
Math.cos(distance/radius)-Math.sin(lat)*Math.sin(end_lat))
|
81
|
+
|
82
|
+
LatLng.new(rad2deg(end_lat),rad2deg(end_lng))
|
83
|
+
end
|
84
|
+
|
85
|
+
# Returns the midpoint, given two points. Returns a LatLng.
|
86
|
+
# Typically, the instance method will be used instead of this method.
|
87
|
+
# Valid option:
|
88
|
+
# :units - valid values are :miles or :kms (:miles is the default)
|
89
|
+
def midpoint_between(from,to,options={})
|
90
|
+
from=GeoKit::LatLng.normalize(from)
|
91
|
+
|
92
|
+
units = options[:units] || GeoKit::default_units
|
93
|
+
|
94
|
+
heading=from.heading_to(to)
|
95
|
+
distance=from.distance_to(to,options)
|
96
|
+
midpoint=from.endpoint(heading,distance/2,options)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Geocodes a location using the multi geocoder.
|
100
|
+
def geocode(location)
|
101
|
+
res = Geocoders::MultiGeocoder.geocode(location)
|
102
|
+
return res if res.success
|
103
|
+
raise GeoKit::Geocoders::GeocodeError
|
104
|
+
end
|
105
|
+
|
106
|
+
protected
|
107
|
+
|
108
|
+
def deg2rad(degrees)
|
109
|
+
degrees.to_f / 180.0 * Math::PI
|
110
|
+
end
|
111
|
+
|
112
|
+
def rad2deg(rad)
|
113
|
+
rad.to_f * 180.0 / Math::PI
|
114
|
+
end
|
115
|
+
|
116
|
+
def to_heading(rad)
|
117
|
+
(rad2deg(rad)+360)%360
|
118
|
+
end
|
119
|
+
|
120
|
+
# Returns the multiplier used to obtain the correct distance units.
|
121
|
+
def units_sphere_multiplier(units)
|
122
|
+
units == :miles ? EARTH_RADIUS_IN_MILES : EARTH_RADIUS_IN_KMS
|
123
|
+
end
|
124
|
+
|
125
|
+
# Returns the number of units per latitude degree.
|
126
|
+
def units_per_latitude_degree(units)
|
127
|
+
units == :miles ? MILES_PER_LATITUDE_DEGREE : KMS_PER_LATITUDE_DEGREE
|
128
|
+
end
|
129
|
+
|
130
|
+
# Returns the number units per longitude degree.
|
131
|
+
def units_per_longitude_degree(lat, units)
|
132
|
+
miles_per_longitude_degree = (LATITUDE_DEGREES * Math.cos(lat * PI_DIV_RAD)).abs
|
133
|
+
units == :miles ? miles_per_longitude_degree : miles_per_longitude_degree * KMS_PER_MILE
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# -----------------------------------------------------------------------------------------------
|
138
|
+
# Instance methods below here
|
139
|
+
# -----------------------------------------------------------------------------------------------
|
140
|
+
|
141
|
+
# Extracts a LatLng instance. Use with models that are acts_as_mappable
|
142
|
+
def to_lat_lng
|
143
|
+
return self if instance_of?(GeoKit::LatLng) || instance_of?(GeoKit::GeoLoc)
|
144
|
+
return LatLng.new(send(self.class.lat_column_name),send(self.class.lng_column_name)) if self.class.respond_to?(:acts_as_mappable)
|
145
|
+
return nil
|
146
|
+
end
|
147
|
+
|
148
|
+
# Returns the distance from another point. The other point parameter is
|
149
|
+
# required to have lat and lng attributes. Valid options are:
|
150
|
+
# :units - valid values are :miles or :kms (:miles is the default)
|
151
|
+
# :formula - valid values are :flat or :sphere (:sphere is the default)
|
152
|
+
def distance_to(other, options={})
|
153
|
+
self.class.distance_between(self, other, options)
|
154
|
+
end
|
155
|
+
alias distance_from distance_to
|
156
|
+
|
157
|
+
# Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
|
158
|
+
# to the given point. The given point can be a LatLng or a string to be Geocoded
|
159
|
+
def heading_to(other)
|
160
|
+
self.class.heading_between(self,other)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
|
164
|
+
# FROM the given point. The given point can be a LatLng or a string to be Geocoded
|
165
|
+
def heading_from(other)
|
166
|
+
self.class.heading_between(other,self)
|
167
|
+
end
|
168
|
+
|
169
|
+
# Returns the endpoint, given a heading (in degrees) and distance.
|
170
|
+
# Valid option:
|
171
|
+
# :units - valid values are :miles or :kms (:miles is the default)
|
172
|
+
def endpoint(heading,distance,options={})
|
173
|
+
self.class.endpoint(self,heading,distance,options)
|
174
|
+
end
|
175
|
+
|
176
|
+
# Returns the midpoint, given another point on the map.
|
177
|
+
# Valid option:
|
178
|
+
# :units - valid values are :miles or :kms (:miles is the default)
|
179
|
+
def midpoint_to(other, options={})
|
180
|
+
self.class.midpoint_between(self,other,options)
|
181
|
+
end
|
182
|
+
|
183
|
+
end
|
184
|
+
|
185
|
+
class LatLng
|
186
|
+
include Mappable
|
187
|
+
|
188
|
+
attr_accessor :lat, :lng
|
189
|
+
|
190
|
+
# Accepts latitude and longitude or instantiates an empty instance
|
191
|
+
# if lat and lng are not provided. Converted to floats if provided
|
192
|
+
def initialize(lat=nil, lng=nil)
|
193
|
+
lat = lat.to_f if lat && !lat.is_a?(Numeric)
|
194
|
+
lng = lng.to_f if lng && !lng.is_a?(Numeric)
|
195
|
+
@lat = lat
|
196
|
+
@lng = lng
|
197
|
+
end
|
198
|
+
|
199
|
+
# Latitude attribute setter; stored as a float.
|
200
|
+
def lat=(lat)
|
201
|
+
@lat = lat.to_f if lat
|
202
|
+
end
|
203
|
+
|
204
|
+
# Longitude attribute setter; stored as a float;
|
205
|
+
def lng=(lng)
|
206
|
+
@lng=lng.to_f if lng
|
207
|
+
end
|
208
|
+
|
209
|
+
# Returns the lat and lng attributes as a comma-separated string.
|
210
|
+
def ll
|
211
|
+
"#{lat},#{lng}"
|
212
|
+
end
|
213
|
+
|
214
|
+
#returns a string with comma-separated lat,lng values
|
215
|
+
def to_s
|
216
|
+
ll
|
217
|
+
end
|
218
|
+
|
219
|
+
#returns a two-element array
|
220
|
+
def to_a
|
221
|
+
[lat,lng]
|
222
|
+
end
|
223
|
+
# Returns true if the candidate object is logically equal. Logical equivalence
|
224
|
+
# is true if the lat and lng attributes are the same for both objects.
|
225
|
+
def ==(other)
|
226
|
+
other.is_a?(LatLng) ? self.lat == other.lat && self.lng == other.lng : false
|
227
|
+
end
|
228
|
+
|
229
|
+
# A *class* method to take anything which can be inferred as a point and generate
|
230
|
+
# a LatLng from it. You should use this anything you're not sure what the input is,
|
231
|
+
# and want to deal with it as a LatLng if at all possible. Can take:
|
232
|
+
# 1) two arguments (lat,lng)
|
233
|
+
# 2) a string in the format "37.1234,-129.1234" or "37.1234 -129.1234"
|
234
|
+
# 3) a string which can be geocoded on the fly
|
235
|
+
# 4) an array in the format [37.1234,-129.1234]
|
236
|
+
# 5) a LatLng or GeoLoc (which is just passed through as-is)
|
237
|
+
# 6) anything which acts_as_mappable -- a LatLng will be extracted from it
|
238
|
+
def self.normalize(thing,other=nil)
|
239
|
+
# if an 'other' thing is supplied, normalize the input by creating an array of two elements
|
240
|
+
thing=[thing,other] if other
|
241
|
+
|
242
|
+
if thing.is_a?(String)
|
243
|
+
thing.strip!
|
244
|
+
if match=thing.match(/(\-?\d+\.?\d*)[, ] ?(\-?\d+\.?\d*)$/)
|
245
|
+
return GeoKit::LatLng.new(match[1],match[2])
|
246
|
+
else
|
247
|
+
res = GeoKit::Geocoders::MultiGeocoder.geocode(thing)
|
248
|
+
return res if res.success
|
249
|
+
raise GeoKit::Geocoders::GeocodeError
|
250
|
+
end
|
251
|
+
elsif thing.is_a?(Array) && thing.size==2
|
252
|
+
return GeoKit::LatLng.new(thing[0],thing[1])
|
253
|
+
elsif thing.is_a?(LatLng) # will also be true for GeoLocs
|
254
|
+
return thing
|
255
|
+
elsif thing.class.respond_to?(:acts_as_mappable) && thing.class.respond_to?(:distance_column_name)
|
256
|
+
return thing.to_lat_lng
|
257
|
+
end
|
258
|
+
|
259
|
+
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.")
|
260
|
+
end
|
261
|
+
|
262
|
+
end
|
263
|
+
|
264
|
+
# This class encapsulates the result of a geocoding call
|
265
|
+
# It's primary purpose is to homogenize the results of multiple
|
266
|
+
# geocoding providers. It also provides some additional functionality, such as
|
267
|
+
# the "full address" method for geocoders that do not provide a
|
268
|
+
# full address in their results (for example, Yahoo), and the "is_us" method.
|
269
|
+
class GeoLoc < LatLng
|
270
|
+
# Location attributes. Full address is a concatenation of all values. For example:
|
271
|
+
# 100 Spear St, San Francisco, CA, 94101, US
|
272
|
+
attr_accessor :street_address, :city, :state, :zip, :country_code, :full_address
|
273
|
+
# Attributes set upon return from geocoding. Success will be true for successful
|
274
|
+
# geocode lookups. The provider will be set to the name of the providing geocoder.
|
275
|
+
# Finally, precision is an indicator of the accuracy of the geocoding.
|
276
|
+
attr_accessor :success, :provider, :precision
|
277
|
+
# Street number and street name are extracted from the street address attribute.
|
278
|
+
attr_reader :street_number, :street_name
|
279
|
+
|
280
|
+
# Constructor expects a hash of symbols to correspond with attributes.
|
281
|
+
def initialize(h={})
|
282
|
+
@street_address=h[:street_address]
|
283
|
+
@city=h[:city]
|
284
|
+
@state=h[:state]
|
285
|
+
@zip=h[:zip]
|
286
|
+
@country_code=h[:country_code]
|
287
|
+
@success=false
|
288
|
+
@precision='unknown'
|
289
|
+
super(h[:lat],h[:lng])
|
290
|
+
end
|
291
|
+
|
292
|
+
# Returns true if geocoded to the United States.
|
293
|
+
def is_us?
|
294
|
+
country_code == 'US'
|
295
|
+
end
|
296
|
+
|
297
|
+
# full_address is provided by google but not by yahoo. It is intended that the google
|
298
|
+
# geocoding method will provide the full address, whereas for yahoo it will be derived
|
299
|
+
# from the parts of the address we do have.
|
300
|
+
def full_address
|
301
|
+
@full_address ? @full_address : to_geocodeable_s
|
302
|
+
end
|
303
|
+
|
304
|
+
# Extracts the street number from the street address if the street address
|
305
|
+
# has a value.
|
306
|
+
def street_number
|
307
|
+
street_address[/(\d*)/] if street_address
|
308
|
+
end
|
309
|
+
|
310
|
+
# Returns the street name portion of the street address.
|
311
|
+
def street_name
|
312
|
+
street_address[street_number.length, street_address.length].strip if street_address
|
313
|
+
end
|
314
|
+
|
315
|
+
# gives you all the important fields as key-value pairs
|
316
|
+
def hash
|
317
|
+
res={}
|
318
|
+
[: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) }
|
319
|
+
res
|
320
|
+
end
|
321
|
+
alias to_hash hash
|
322
|
+
|
323
|
+
# Sets the city after capitalizing each word within the city name.
|
324
|
+
def city=(city)
|
325
|
+
@city = city.titleize if city
|
326
|
+
end
|
327
|
+
|
328
|
+
# Sets the street address after capitalizing each word within the street address.
|
329
|
+
def street_address=(address)
|
330
|
+
@street_address = address.titleize if address
|
331
|
+
end
|
332
|
+
|
333
|
+
# Returns a comma-delimited string consisting of the street address, city, state,
|
334
|
+
# zip, and country code. Only includes those attributes that are non-blank.
|
335
|
+
def to_geocodeable_s
|
336
|
+
a=[street_address, city, state, zip, country_code].compact
|
337
|
+
a.delete_if { |e| !e || e == '' }
|
338
|
+
a.join(', ')
|
339
|
+
end
|
340
|
+
|
341
|
+
# Returns a string representation of the instance.
|
342
|
+
def to_s
|
343
|
+
"Provider: #{provider}\n Street: #{street_address}\nCity: #{city}\nState: #{state}\nZip: #{zip}\nLatitude: #{lat}\nLongitude: #{lng}\nCountry: #{country_code}\nSuccess: #{success}"
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
# Bounds represents a rectangular bounds, defined by the SW and NE corners
|
348
|
+
class Bounds
|
349
|
+
# sw and ne are LatLng objects
|
350
|
+
attr_accessor :sw, :ne
|
351
|
+
|
352
|
+
# provide sw and ne to instantiate a new Bounds instance
|
353
|
+
def initialize(sw,ne)
|
354
|
+
raise ArguementError if !(sw.is_a?(GeoKit::LatLng) && ne.is_a?(GeoKit::LatLng))
|
355
|
+
@sw,@ne=sw,ne
|
356
|
+
end
|
357
|
+
|
358
|
+
#returns the a single point which is the center of the rectangular bounds
|
359
|
+
def center
|
360
|
+
@sw.midpoint_to(@ne)
|
361
|
+
end
|
362
|
+
|
363
|
+
# a simple string representation:sw,ne
|
364
|
+
def to_s
|
365
|
+
"#{@sw.to_s},#{@ne.to_s}"
|
366
|
+
end
|
367
|
+
|
368
|
+
# a two-element array of two-element arrays: sw,ne
|
369
|
+
def to_a
|
370
|
+
[@sw.to_a, @ne.to_a]
|
371
|
+
end
|
372
|
+
|
373
|
+
# Returns true if the bounds contain the passed point.
|
374
|
+
# allows for bounds which cross the meridian
|
375
|
+
def contains?(point)
|
376
|
+
point=GeoKit::LatLng.normalize(point)
|
377
|
+
res = point.lat > @sw.lat && point.lat < @ne.lat
|
378
|
+
if crosses_meridian?
|
379
|
+
res &= point.lng < @ne.lng || point.lng > @sw.lng
|
380
|
+
else
|
381
|
+
res &= point.lng < @ne.lng && point.lng > @sw.lng
|
382
|
+
end
|
383
|
+
res
|
384
|
+
end
|
385
|
+
|
386
|
+
# returns true if the bounds crosses the international dateline
|
387
|
+
def crosses_meridian?
|
388
|
+
@sw.lng > @ne.lng
|
389
|
+
end
|
390
|
+
|
391
|
+
# Returns true if the candidate object is logically equal. Logical equivalence
|
392
|
+
# is true if the lat and lng attributes are the same for both objects.
|
393
|
+
def ==(other)
|
394
|
+
other.is_a?(Bounds) ? self.sw == other.sw && self.ne == other.ne : false
|
395
|
+
end
|
396
|
+
|
397
|
+
class <<self
|
398
|
+
|
399
|
+
# returns an instance of bounds which completely encompases the given circle
|
400
|
+
def from_point_and_radius(point,radius,options={})
|
401
|
+
point=LatLng.normalize(point)
|
402
|
+
p0=point.endpoint(0,radius,options)
|
403
|
+
p90=point.endpoint(90,radius,options)
|
404
|
+
p180=point.endpoint(180,radius,options)
|
405
|
+
p270=point.endpoint(270,radius,options)
|
406
|
+
sw=GeoKit::LatLng.new(p180.lat,p270.lng)
|
407
|
+
ne=GeoKit::LatLng.new(p0.lat,p90.lng)
|
408
|
+
GeoKit::Bounds.new(sw,ne)
|
409
|
+
end
|
410
|
+
|
411
|
+
# Takes two main combinations of arguements to create a bounds:
|
412
|
+
# point,point (this is the only one which takes two arguments
|
413
|
+
# [point,point]
|
414
|
+
# . . . where a point is anything LatLng#normalize can handle (which is quite a lot)
|
415
|
+
#
|
416
|
+
# NOTE: everything combination is assumed to pass points in the order sw, ne
|
417
|
+
def normalize (thing,other=nil)
|
418
|
+
# maybe this will be simple -- an actual bounds object is passed, and we can all go home
|
419
|
+
return thing if thing.is_a? Bounds
|
420
|
+
|
421
|
+
# no? OK, if there's no "other," the thing better be a two-element array
|
422
|
+
thing,other=thing if !other && thing.is_a?(Array) && thing.size==2
|
423
|
+
|
424
|
+
# Now that we're set with a thing and another thing, let LatLng do the heavy lifting.
|
425
|
+
# Exceptions may be thrown
|
426
|
+
Bounds.new(GeoKit::LatLng.normalize(thing),GeoKit::LatLng.normalize(other))
|
427
|
+
end
|
428
|
+
end
|
429
|
+
end
|
430
|
+
end
|