##
# Add geocoding functionality (via Google) to any object.
#
module Geocoder
##
# Implementation of 'included' hook method.
#
def self.included(base)
base.extend ClassMethods
base.class_eval do
# named scope: geocoded objects
named_scope :geocoded,
:conditions => "#{geocoder_options[:latitude]} IS NOT NULL " +
"AND #{geocoder_options[:longitude]} IS NOT NULL"
# named scope: not-geocoded objects
named_scope :not_geocoded,
:conditions => "#{geocoder_options[:latitude]} IS NULL " +
"OR #{geocoder_options[:longitude]} IS NULL"
##
# Find all objects within a radius (in miles) of the given location
# (address string). Location (the first argument) may be either a string
# to geocode or an array of coordinates ([lat,long]).
#
named_scope :near, lambda{ |location, *args|
latitude, longitude = location.is_a?(Array) ?
location : Geocoder.fetch_coordinates(location)
if latitude and longitude
near_scope_options(latitude, longitude, *args)
else
{}
end
}
end
end
##
# Methods which will be class methods of the including class.
#
module ClassMethods
##
# DEPRECATED: Please use the +near+ method/named scope instead.
#
def find_near(location, radius = 20, options = {})
warn "Geocoder deprecation warning: the 'find_near' class method is " +
"deprecated, please use the 'near' method, which is a named scope."
near(location, radius, options)
end
##
# 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 near_scope_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
lat_attr = geocoder_options[:latitude]
lon_attr = geocoder_options[:longitude]
{
:select => "*, 3956 * 2 * ASIN(SQRT(" +
"POWER(SIN((#{latitude} - #{lat_attr}) * " +
"PI() / 180 / 2), 2) + COS(#{latitude} * PI()/180) * " +
"COS(#{lat_attr} * PI() / 180) * " +
"POWER(SIN((#{longitude} - #{lon_attr}) * " +
"PI() / 180 / 2), 2) )) as distance",
:conditions => [
"#{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
end
##
# Read the coordinates [lat,lon] of an object. This is not great but it
# seems cleaner than polluting the instance method namespace.
#
def read_coordinates
[:latitude, :longitude].map{ |i| send self.class.geocoder_options[i] }
end
##
# Is this object geocoded? (Does it have latitude and longitude?)
#
def geocoded?
read_coordinates.compact.size > 0
end
##
# Calculate the distance from the object to a point (lat,lon).
# Valid units are defined in distance_between class method.
#
def distance_to(lat, lon, units = :mi)
return nil unless geocoded?
mylat,mylon = read_coordinates
Geocoder.distance_between(mylat, mylon, lat, lon, :units => units)
end
##
# Get other geocoded objects within a given radius (in miles). Takes a
# radius (in miles) and options for passing to the +near+ named scope
# (:order, :limit, and :offset).
#
def nearbys(radius = 20, options = {})
return [] unless geocoded?
options = {:conditions => ["id != ?", id]}.merge(options)
self.class.near(read_coordinates, radius, options) - [self]
end
##
# Fetch coordinates and assign +latitude+ and +longitude+. Also returns
# coordinates as an array: [lat, lon].
#
def fetch_coordinates(save = false)
location = send(self.class.geocoder_options[:method_name])
returning Geocoder.fetch_coordinates(location) do |c|
unless c.blank?
method = (save ? "update" : "write") + "_attribute"
send method, self.class.geocoder_options[:latitude], c[0]
send method, self.class.geocoder_options[:longitude], c[1]
end
end
end
##
# Fetch coordinates and update (save) +latitude+ and +longitude+ data.
#
def fetch_coordinates!
fetch_coordinates(true)
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:
#
# :units :: :mi (default) or :km
#
def self.distance_between(lat1, lon1, lat2, lon2, options = {})
# set default options
options[:units] ||= :mi
# define conversion factors
units = { :mi => 3956, :km => 6371 }
# convert degrees to radians
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
##
# Compute the geographic center (aka geographic midpoint, center of
# gravity) for an array of geocoded objects and/or [lat,lon] arrays
# (can be mixed). Any objects missing coordinates are ignored. Follows
# the procedure documented at http://www.geomidpoint.com/calculation.html.
#
def self.geographic_center(points)
# convert objects to [lat,lon] arrays and remove nils
points = points.map{ |p|
p.is_a?(Array) ? p : (p.geocoded?? p.read_coordinates : nil)
}.compact
# convert degrees to radians
points.map!{ |p| [to_radians(p[0]), to_radians(p[1])] }
# convert to Cartesian coordinates
x = []; y = []; z = []
points.each do |p|
x << Math.cos(p[0]) * Math.cos(p[1])
y << Math.cos(p[0]) * Math.sin(p[1])
z << Math.sin(p[0])
end
# compute average coordinate values
xa, ya, za = [x,y,z].map do |c|
c.inject(0){ |tot,i| tot += i } / c.size.to_f
end
# convert back to latitude/longitude
lon = Math.atan2(ya, xa)
hyp = Math.sqrt(xa**2 + ya**2)
lat = Math.atan2(za, hyp)
# return answer in degrees
[to_degrees(lat), to_degrees(lon)]
end
##
# Convert degrees to radians.
#
def self.to_radians(degrees)
degrees * (Math::PI / 180)
end
##
# Convert radians to degrees.
#
def self.to_degrees(radians)
(radians * 180.0) / Math::PI
end
##
# Query Google for geographic information about the given phrase.
#
def self.search(query)
if doc = _fetch_xml(query)
REXML::Document.new(doc)
end
end
##
# Request an XML geo search result from Google.
# This method is not intended for general use (prefer Geocoder.search).
#
def self._fetch_xml(query)
params = {
:q => query,
:key => GOOGLE_MAPS_API_KEY,
:output => "xml",
:sensor => "false",
:oe => "utf8"
}
url = "http://maps.google.com/maps/geo?" + params.to_query
# Query geocoder and make sure it responds quickly.
begin
resp = nil
timeout(3) do
Net::HTTP.get_response(URI.parse(url)).body
end
rescue SocketError, TimeoutError
return nil
end
end
end
##
# Add geocoded_by method to ActiveRecord::Base so Geocoder is accessible.
#
ActiveRecord::Base.class_eval do
##
# Set attribute names and include the Geocoder module.
#
def self.geocoded_by(method_name = :location, options = {})
class_inheritable_reader :geocoder_options
write_inheritable_attribute :geocoder_options, {
:method_name => method_name,
:latitude => options[:latitude] || :latitude,
:longitude => options[:longitude] || :longitude
}
include Geocoder
end
end