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,
Alex Reisner
committed
:conditions => "#{geocoder_options[:latitude]} IS NOT NULL " +
"AND #{geocoder_options[:longitude]} IS NOT NULL"
# named scope: not-geocoded objects
named_scope :not_geocoded,
Alex Reisner
committed
:conditions => "#{geocoder_options[:latitude]} IS NULL " +
"OR #{geocoder_options[:longitude]} IS NULL"
##
# 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
##
# 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
Alex Reisner
committed
# Generate hash.
Alex Reisner
committed
lat_attr = geocoder_options[:latitude]
lon_attr = geocoder_options[:longitude]
{
:select => "*, 3956 * 2 * ASIN(SQRT(" +
Alex Reisner
committed
"POWER(SIN((#{latitude} - #{lat_attr}) * " +
"PI() / 180 / 2), 2) + COS(#{latitude} * PI()/180) * " +
Alex Reisner
committed
"COS(#{lat_attr} * PI() / 180) * " +
"POWER(SIN((#{longitude} - #{lon_attr}) * " +
"PI() / 180 / 2), 2) )) as distance",
:conditions => [
Alex Reisner
committed
"#{lat_attr} BETWEEN ? AND ? AND " +
"#{lon_attr} BETWEEN ? AND ?",
lat_lo, lat_hi, lon_lo, lon_hi],
:having => "distance <= #{radius}",
:order => options[:order],
:limit => limit
}
end
##
Alex Reisner
committed
# Get the coordinates [lat,lon] of an object. This is not great but it
# seems cleaner than polluting the object method namespace.
Alex Reisner
committed
def _get_coordinates(object)
[object.send(geocoder_options[:latitude]),
object.send(geocoder_options[:longitude])]
end
end
##
# Is this object geocoded? (Does it have latitude and longitude?)
#
def geocoded?
Alex Reisner
committed
self.class._get_coordinates(self).compact.size > 0
##
# 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)
Alex Reisner
committed
mylat,mylon = self.class._get_coordinates(self)
Geocoder.distance_between(mylat, mylon, lat, lon, :units => units)
# Get other geocoded objects within a given radius.
# The object must be geocoded before this method is called.
#
def nearbys(radius = 20)
return [] unless geocoded?
Alex Reisner
committed
lat,lon = self.class._get_coordinates(self)
self.class.find_near([lat, lon], radius) - [self]
end
##
# Fetch coordinates based on the object's location.
# Returns an array <tt>[lat,lon]</tt>.
Alex Reisner
committed
def fetch_coordinates
Alex Reisner
committed
location = read_attribute(self.class.geocoder_options[:method_name])
Geocoder.fetch_coordinates(location)
# Fetch coordinates and assign +latitude+ and +longitude+.
def fetch_coordinates!
Alex Reisner
committed
returning fetch_coordinates do |c|
unless c.blank?
Alex Reisner
committed
write_attribute(self.class.geocoder_options[:latitude], c[0])
write_attribute(self.class.geocoder_options[:longitude], 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)