##
# Add geocoding functionality to any ActiveRecord object.
#
module Geocoder
module ActiveRecord
##
# Implementation of 'included' hook method.
#
def self.included(base)
base.extend ClassMethods
base.class_eval do
# scope: geocoded objects
scope :geocoded,
:conditions => "#{geocoder_options[:latitude]} IS NOT NULL " +
"AND #{geocoder_options[:longitude]} IS NOT NULL"
# scope: not-geocoded objects
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]).
#
scope :near, lambda{ |location, *args|
latitude, longitude = location.is_a?(Array) ?
location : Geocoder::Lookup.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
##
# Get options hash suitable for passing to ActiveRecord.find to get
# records within a radius (in miles) of the given point.
# Options hash may include:
#
# +units+ :: :mi (default) or :km
# +exclude+ :: an object to exclude (used by the #nearbys method)
# +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 OFFSET SQL clause)
# +select+ :: string with the SELECT SQL fragment (e.g. “id, name”)
#
def near_scope_options(latitude, longitude, radius = 20, options = {})
radius *= Geocoder::Calculations.km_in_mi if options[:units] == :km
if ::ActiveRecord::Base.connection.adapter_name == "SQLite"
approx_near_scope_options(latitude, longitude, radius, options)
else
full_near_scope_options(latitude, longitude, radius, options)
end
end
private # ----------------------------------------------------------------
##
# Scope options hash for use with a database that supports POWER(),
# SQRT(), PI(), and trigonometric functions (SIN(), COS(), and ASIN()).
#
# Taken from the excellent tutorial at:
# http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL
#
def full_near_scope_options(latitude, longitude, radius, options)
lat_attr = geocoder_options[:latitude]
lon_attr = geocoder_options[:longitude]
distance = "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) ))"
options[:order] ||= "#{distance} ASC"
default_near_scope_options(latitude, longitude, radius, options).merge(
:select => "#{options[:select] || '*'}, #{distance} AS distance",
:having => "#{distance} <= #{radius}"
)
end
##
# Scope options hash for use with a database without trigonometric
# functions, like SQLite. Approach is to find objects within a square
# rather than a circle, so results are very approximate (will include
# objects outside the given radius).
#
def approx_near_scope_options(latitude, longitude, radius, options)
default_near_scope_options(latitude, longitude, radius, options).merge(
:select => options[:select] || nil
)
end
##
# Options used for any near-like scope.
#
def default_near_scope_options(latitude, longitude, radius, options)
lat_attr = geocoder_options[:latitude]
lon_attr = geocoder_options[:longitude]
conditions = \
["#{lat_attr} BETWEEN ? AND ? AND #{lon_attr} BETWEEN ? AND ?"] +
coordinate_bounds(latitude, longitude, radius)
if obj = options[:exclude]
conditions[0] << " AND id != ?"
conditions << obj.id
end
{
:group => columns.map{ |c| "#{table_name}.#{c.name}" }.join(','),
:order => options[:order],
:limit => options[:limit],
:offset => options[:offset],
:conditions => conditions
}
end
##
# Get the rough high/low lat/long bounds for a geographic point and
# radius. Returns an array: [lat_lo, lat_hi, lon_lo, lon_hi].
# Used to constrain search to a (radius x radius) square.
#
def coordinate_bounds(latitude, longitude, radius)
radius = radius.to_f
factor = (Math::cos(latitude * Math::PI / 180.0) * 69.0).abs
[
latitude - (radius / 69.0),
latitude + (radius / 69.0),
longitude - (radius / factor),
longitude + (radius / factor)
]
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).
#
# :units :: :mi (default) or :km
#
def distance_to(lat, lon, units = :mi)
return nil unless geocoded?
mylat,mylon = read_coordinates
Geocoder::Calculations.distance_between(mylat, mylon, lat, lon, :units => units)
end
##
# Get other geocoded objects within a given radius.
#
# :units :: :mi (default) or :km
#
def nearbys(radius = 20, units = :mi)
return [] unless geocoded?
options = {:exclude => self, :units => units}
self.class.near(read_coordinates, radius, options)
end
##
# Fetch coordinates and assign +latitude+ and +longitude+. Also returns
# coordinates as an array: [lat, lon].
#
def fetch_coordinates(save = false)
address_method = self.class.geocoder_options[:user_address]
unless address_method.is_a? Symbol
raise Geocoder::ConfigurationError,
"You are attempting to fetch coordinates but have not specified " +
"a method which provides an address for the object."
end
coords = Geocoder::Lookup.coordinates(send(address_method))
unless coords.blank?
method = (save ? "update" : "write") + "_attribute"
send method, self.class.geocoder_options[:latitude], coords[0]
send method, self.class.geocoder_options[:longitude], coords[1]
end
coords
end
##
# Fetch coordinates and update (save) +latitude+ and +longitude+ data.
#
def fetch_coordinates!
fetch_coordinates(true)
end
##
# Fetch address and assign +address+ attribute. Also returns
# address as a string.
#
def fetch_address(save = false)
lat_attr = self.class.geocoder_options[:latitude]
lon_attr = self.class.geocoder_options[:longitude]
unless lat_attr.is_a?(Symbol) and lon_attr.is_a?(Symbol)
raise Geocoder::ConfigurationError,
"You are attempting to fetch an address but have not specified " +
"attributes which provide coordinates for the object."
end
address = Geocoder::Lookup.address(send(lat_attr), send(lon_attr))
unless address.blank?
method = (save ? "update" : "write") + "_attribute"
send method, self.class.geocoder_options[:fetched_address], address
end
address
end
##
# Fetch address and update (save) +address+ data.
#
def fetch_address!
fetch_address(true)
end
end
end