Newer
Older
Alex Reisner
committed
# Add geocoding functionality (via Google) to any object.
##
# Implementation of 'included' hook method.
#
def self.included(base)
base.class_eval do
# named scope: geocoded objects
named_scope :geocoded,
:conditions => "latitude IS NOT NULL AND longitude IS NOT NULL"
# named scope: not-geocoded objects
named_scope :not_geocoded,
:conditions => "latitude IS NULL OR longitude IS NULL"
end
end
##
# Methods which will be class methods of the including class.
#
module ClassMethods
##
# Find all objects within a radius (in miles) of the given location
Alex Reisner
committed
# (address string). Location (the first argument) may be either a string
# to geocode or an array of coordinates (<tt>[lat,long]</tt>).
Alex Reisner
committed
def find_near(location, radius = 20, options = {})
latitude, longitude = location.is_a?(Array) ?
location : Geocoder.fetch_coordinates(location)
return [] unless (latitude and longitude)
all(find_near_options(latitude, longitude, radius, options))
Alex Reisner
committed
##
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
# Get options hash suitable for passing to ActiveRecord.find to get
# records within a radius (in miles) of the given point.
# Taken from excellent tutorial at:
# http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL
#
# Options hash may include:
#
# +order+ :: column(s) for ORDER BY SQL clause
# +limit+ :: number of records to return (for LIMIT SQL clause)
# +offset+ :: number of records to skip (for LIMIT SQL clause)
#
def find_near_options(latitude, longitude, radius = 20, options = {})
# Set defaults/clean up arguments.
options[:order] ||= 'distance ASC'
radius = radius.to_i
# Constrain search to a (radius x radius) square.
factor = (Math::cos(latitude * Math::PI / 180.0) * 69.0).abs
lon_lo = longitude - (radius / factor);
lon_hi = longitude + (radius / factor);
lat_lo = latitude - (radius / 69.0);
lat_hi = latitude + (radius / 69.0);
# Build limit clause.
limit = nil
if options[:limit] or options[:offset]
options[:offset] ||= 0
limit = "#{options[:offset]},#{options[:limit]}"
end
# Generate hash.
{
:select => "*, 3956 * 2 * ASIN(SQRT(" +
"POWER(SIN((#{latitude} - #{geocoder_latitude_attr}) * " +
"PI() / 180 / 2), 2) + COS(#{latitude} * PI()/180) * " +
"COS(#{geocoder_latitude_attr} * PI() / 180) * " +
"POWER(SIN((#{longitude} - #{geocoder_longitude_attr}) * " +
"PI() / 180 / 2), 2) )) as distance",
:conditions => [
"#{geocoder_latitude_attr} BETWEEN ? AND ? AND " +
"#{geocoder_longitude_attr} BETWEEN ? AND ?",
lat_lo, lat_hi, lon_lo, lon_hi],
:having => "distance <= #{radius}",
:order => options[:order],
:limit => limit
}
end
##
# Name of the method that returns the search string.
Alex Reisner
committed
#
def geocoder_method_name
defined?(@geocoder_method_name) ?
@geocoder_method_name : :location
end
##
# Name of the latitude attribute.
#
def geocoder_latitude_attr
defined?(@geocoder_latitude_attr) ?
@geocoder_latitude_attr : :latitude
end
##
# Name of the longitude attribute.
#
def geocoder_longitude_attr
defined?(@geocoder_longitude_attr) ?
@geocoder_longitude_attr : :longitude
Alex Reisner
committed
end
##
# Calculate the distance from the object to a point (lat,lon). Valid units
# are defined in <tt>distance_between</tt> class method.
#
def distance_to(lat, lon, units = :mi)
mylat = read_attribute(self.class.geocoder_latitude_attr)
mylon = read_attribute(self.class.geocoder_longitude_attr)
Geocoder.distance_between(mylat, mylon, lat, lon, :units => units)
# Fetch coordinates based on the object's location. Returns an
Alex Reisner
committed
def fetch_coordinates
location = read_attribute(self.class.geocoder_method_name)
Geocoder.fetch_coordinates(location)
Alex Reisner
committed
# Fetch and assign +latitude+ and +longitude+.
Alex Reisner
committed
def fetch_and_assign_coordinates
Alex Reisner
committed
returning fetch_coordinates do |c|
unless c.blank?
write_attribute(self.class.geocoder_latitude_attr, c[0])
write_attribute(self.class.geocoder_longitude_attr, c[1])
Alex Reisner
committed
end
##
# Query Google for the coordinates of the given phrase.
# Returns array [lat,lon] if found, nil if not found or if network error.
#
def self.fetch_coordinates(query)
return nil unless doc = self.search(query)
# Make sure search found a result.
e = doc.elements['kml/Response/Status/code']
return nil unless (e and e.text == "200")
# Isolate the relevant part of the result.
place = doc.elements['kml/Response/Placemark']
# If there are multiple results, blindly use the first.
coords = place.elements['Point/coordinates'].text
coords.split(',')[0...2].reverse.map{ |i| i.to_f }
end
# Calculate the distance between two points on Earth (Haversine formula).
# Takes two sets of coordinates and an options hash:
Alex Reisner
committed
#
# +units+ :: <tt>:mi</tt> for miles (default), <tt>:km</tt> for kilometers
#
def self.distance_between(lat1, lon1, lat2, lon2, options = {})
# set default options
options[:units] ||= :mi
# define available units
units = { :mi => 3956, :km => 6371 }
# convert degrees to radians
Alex Reisner
committed
lat1 = to_radians(lat1)
lon1 = to_radians(lon1)
lat2 = to_radians(lat2)
lon2 = to_radians(lon2)
# compute distances
dlat = (lat1 - lat2).abs
dlon = (lon1 - lon2).abs
a = (Math.sin(dlat / 2))**2 + Math.cos(lat1) *
(Math.sin(dlon / 2))**2 * Math.cos(lat2)
c = 2 * Math.atan2( Math.sqrt(a), Math.sqrt(1-a))
c * units[options[:units]]
end
Alex Reisner
committed
# Convert degrees to radians.
#
def self.to_radians(degrees)
degrees * (Math::PI / 180)
end
##
# Query Google for geographic information about the given phrase.
# Returns the XML response as a hash. This method is not intended for
# general use (prefer Geocoder.search).
#
def self.search(query)
params = { :q => query, :output => "xml" }
url = "http://maps.google.com/maps/geo?" + params.to_query
# Query geocoder and make sure it responds quickly.
begin
resp = nil
timeout(3) do
resp = Net::HTTP.get_response(URI.parse(url))
end
rescue SocketError, TimeoutError
return nil
end
# Google's XML document has incorrect encoding (says UTF-8 but is actually
# ISO 8859-1). Have to fix this or REXML won't parse correctly.
# This may be fixed in the future; see the bug report at:
# http://code.google.com/p/gmaps-api-issues/issues/detail?id=233
doc = resp.body.sub('UTF-8', 'ISO-8859-1')
REXML::Document.new(doc)