Skip to content
Snippets Groups Projects
geocoder.rb 6.44 KiB
Newer Older
Alex Reisner's avatar
Alex Reisner committed
##
# Add geocoding functionality (via Google) to any object.
Alex Reisner's avatar
Alex Reisner committed
#
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 => "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
    
Alex Reisner's avatar
Alex Reisner committed
  ##
  # Query Google for the coordinates of the given phrase.
Alex Reisner's avatar
Alex Reisner committed
  # Returns array [lat,lon] if found, nil if not found or if network error.
Alex Reisner's avatar
Alex Reisner committed
  #
  def self.fetch_coordinates(query)
    return nil unless doc = self.search(query)
Alex Reisner's avatar
Alex Reisner committed
    
    # 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']
Alex Reisner's avatar
Alex Reisner committed

    # If there are multiple results, blindly use the first.
    coords = place.elements['Point/coordinates'].text
Alex Reisner's avatar
Alex Reisner committed
    coords.split(',')[0...2].reverse.map{ |i| i.to_f }
  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
    # (address string). Location (the first argument) may be either a string
    # to geocode or an array of coordinates (<tt>[lat,long]</tt>).
    def find_near(location, radius = 20, options = {})
      latitude, longitude = location.is_a?(Array) ?
        location : Geocoder.fetch_coordinates(location)
      return [] unless (latitude and longitude)
      all(Geocoder.find_near_options(latitude, longitude, radius, options))
    
    ##
    # Get the name of the method that returns the search string.
    #
    def geocoder_method_name
      defined?(@geocoder_method_name) ? @geocoder_method_name : :location
    end
Alex Reisner's avatar
Alex Reisner committed
  ##
  # 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)
    Geocoder.distance_between(latitude, longitude, lat, lon, :units => units)
  end
  
Alex Reisner's avatar
Alex Reisner committed
  ##
Alex Reisner's avatar
Alex Reisner committed
  # Fetch coordinates based on the object's object's +location+. Returns an
  # array <tt>[lat,lon]</tt>.
Alex Reisner's avatar
Alex Reisner committed
  #
  def fetch_coordinates
    Geocoder.fetch_coordinates(send(self.class.geocoder_method_name))
Alex Reisner's avatar
Alex Reisner committed
  end
  
  ##
  # Fetch and assign +latitude+ and +longitude+.
Alex Reisner's avatar
Alex Reisner committed
  #
    returning fetch_coordinates do |c|
      unless c.blank?
        self.latitude = c[0]
        self.longitude = c[1]
      end
Alex Reisner's avatar
Alex Reisner committed
    end
  end

  # Calculate the distance between two points on Earth (Haversine formula).
  # Takes two sets of coordinates and an options hash:
  # +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
    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's avatar
Alex Reisner committed
  ##
  # Convert degrees to radians.
  #
  def self.to_radians(degrees)
    degrees * (Math::PI / 180)
  end
  
  ##
  # Get options hash suitable for passing to ActiveRecord.find to get
  # records within a radius (in miles) of the given point.
Alex Reisner's avatar
Alex Reisner committed
  # Taken from excellent tutorial at:
  # http://www.scribd.com/doc/2569355/Geo-Distance-Search-with-MySQL
  # 
  # Options hash may include:
  # 
  # +latitude+  :: name of column storing latitude data
  # +longitude+ :: name of column storing longitude data
  # +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)
Alex Reisner's avatar
Alex Reisner committed
  #
  def self.find_near_options(latitude, longitude, radius = 20, options = {})
Alex Reisner's avatar
Alex Reisner committed
    
Alex Reisner's avatar
Alex Reisner committed
    options[:latitude]  ||= 'latitude'
    options[:longitude] ||= 'longitude'
    options[:order]     ||= 'distance ASC'
Alex Reisner's avatar
Alex Reisner committed
    
    # 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);
    if options[:limit] or options[:offset]
      options[:offset] ||= 0
      limit = "#{options[:offset]},#{options[:limit]}"
Alex Reisner's avatar
Alex Reisner committed

    # Generate hash.
    {
      :select => "*, 3956 * 2 * ASIN(SQRT(" +
        "POWER(SIN((#{latitude} - #{options[:latitude]}) * " +
        "PI() / 180 / 2), 2) + COS(#{latitude} * PI()/180) * " +
        "COS(#{options[:latitude]} * PI() / 180) * " +
        "POWER(SIN((#{longitude} - #{options[:longitude]}) * " +
        "PI() / 180 / 2), 2) )) as distance",
      :conditions => [
        "#{options[:latitude]} BETWEEN ? AND ? AND " +
        "#{options[:longitude]} BETWEEN ? AND ?",
        lat_lo, lat_hi, lon_lo, lon_hi],
      :having => "distance <= #{radius}",
      :order  => options[:order],
      :limit  => limit
    }
Alex Reisner's avatar
Alex Reisner committed
  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')

Alex Reisner's avatar
Alex Reisner committed
  end
end