Skip to content
Snippets Groups Projects
geocoder.rb 12.1 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
      # scope: geocoded objects
      send(Geocoder.scope_method_name, :geocoded,
        :conditions => "#{geocoder_options[:latitude]} IS NOT NULL " +
          "AND #{geocoder_options[:longitude]} IS NOT NULL")
      # scope: not-geocoded objects
      send(Geocoder.scope_method_name, :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 (<tt>[lat,long]</tt>).
      #
      send(Geocoder.scope_method_name, :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)
  ##
  # 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+     :: <tt>:mi</tt> (default) or <tt>:km</tt>
    # +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 *= 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)
      options[:order] ||= 'distance ASC'
      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) ))"
      default_near_scope_options(latitude, longitude, radius, options).merge(
        :select => "#{options[:select] || '*'}, #{distance} AS distance",
        :having => "#{distance} <= #{radius}"
      )
    # 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
    ##
    # 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)
      # Handle conditions. Passing of conditions by developers is deprecated
      # but we will still need to handle conditions so, for example, we can
      # exclude objects by ID from the nearbys method. This is incredibly
      # ugly and doesn't work for a conditions hash: try using Arel?
      if options[:conditions].is_a?(String)
        options[:conditions] = [options[:conditions]]
      end
      if options[:conditions].is_a?(Array)
        conditions[0] = "(#{conditions[0]}) AND #{options[:conditions][0]}"
        conditions << options[:conditions][1]
      end
        :order  => options[:order],
        :limit  => options[:limit],
        :offset => options[:offset],
        :conditions => conditions
    ##
    # Get the rough high/low lat/long bounds for a geographic point and
    # radius. Returns an array: <tt>[lat_lo, lat_hi, lon_lo, lon_hi]</tt>.
    # 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
    ##
    # Conversion factor: km to mi.
    #
    def km_in_mi
      0.621371192
    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] }
  ##
  # Is this object geocoded? (Does it have latitude and longitude?)
  #
  def geocoded?
    read_coordinates.compact.size > 0
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.
Alex Reisner's avatar
Alex Reisner committed
  #
  def distance_to(lat, lon, units = :mi)
    return nil unless geocoded?
    Geocoder.distance_between(mylat, mylon, lat, lon, :units => units)
Alex Reisner's avatar
Alex Reisner committed
  end
Alex Reisner's avatar
Alex Reisner committed
  ##
  # Get other geocoded objects within a given radius.
  # Valid units are defined in <tt>distance_between</tt> class method.
  def nearbys(radius = 20, units = :mi)
    options = {:conditions => ["id != ?", id]}
    if units.is_a? Hash
      warn "DEPRECATION WARNING: The 'options' argument to the nearbys " +
        "method is deprecated and will be removed from rails-geocoder in " +
        "a future version. The second argument is now called 'units' and " +
        "should be a symbol (:mi or :km, :mi is the default). The 'nearbys' " +
        "method now returns a Rails 3 scope so you should specify more " +
        "scopes and/or conditions via chaining. For example: " +
        "city.nearbys(20).order('name').limit(10). Support for Rails 2.x " +
        "will eventually be discontinued."
      options.reverse_merge!(units)
    else
      options.reverse_merge!(:units => units)
    return [] unless geocoded?
    self.class.near(read_coordinates, radius, options)
  # Fetch coordinates and assign +latitude+ and +longitude+. Also returns
  # coordinates as an array: <tt>[lat, lon]</tt>.
Alex Reisner's avatar
Alex Reisner committed
  #
    coords = Geocoder.fetch_coordinates(
      send(self.class.geocoder_options[:method_name])
    )
    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]
Alex Reisner's avatar
Alex Reisner committed
  end
Alex Reisner's avatar
Alex Reisner committed
  ##
  # Fetch coordinates and update (save) +latitude+ and +longitude+ data.
Alex Reisner's avatar
Alex Reisner committed
  #
Alex Reisner's avatar
Alex Reisner committed
  end

  # Calculate the distance between two points on Earth (Haversine formula).
  # Takes two sets of coordinates and an options hash:
  # <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt>
  #
  def self.distance_between(lat1, lon1, lat2, lon2, options = {})
    # set default options
    options[:units] ||= :mi
    # define conversion factors
    conversions = { :mi => 3956, :km => 6371 }
    # convert degrees to radians
    lat1 = to_radians(lat1)
    lon1 = to_radians(lon1)
    lat2 = to_radians(lat2)
    lon2 = to_radians(lon2)
    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 * conversions[options[:units]]
  ##
  # 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
    points.map!{ |p| [to_radians(p[0]), to_radians(p[1])] }
    # convert to Cartesian coordinates
    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])

    # compute average coordinate values
    xa, ya, za = [x,y,z].map do |c|
      c.inject(0){ |tot,i| tot += i } / c.size.to_f
    # convert back to latitude/longitude
    # return answer in degrees
    [to_degrees(lat), to_degrees(lon)]
Alex Reisner's avatar
Alex Reisner committed
  ##
  # Convert degrees to radians.
  #
  def self.to_radians(degrees)
    degrees * (Math::PI / 180)
  ##
  # Convert radians to degrees.
  #
  def self.to_degrees(radians)
    (radians * 180.0) / Math::PI
  end
Alex Reisner's avatar
Alex Reisner committed
  ##
  # Query Google for geographic information about the given phrase.
  # Returns a hash representing a valid geocoder response.
  # Returns nil if non-200 HTTP response, timeout, or other error.
Alex Reisner's avatar
Alex Reisner committed
  def self.search(query)
    doc = _fetch_parsed_response(query)
    doc['status'] == "OK" ? doc : nil
  ##
  # 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)
    # blindly use the first results (assume they are most accurate)
    place = doc['results'].first['geometry']['location']
    ['lat', 'lng'].map{ |i| place[i] }
  # Returns a parsed Google geocoder search result (hash).
  # This method is not intended for general use (prefer Geocoder.search).
  #
  def self._fetch_parsed_response(query)
    if doc = _fetch_raw_response(query)
      ActiveSupport::JSON.decode(doc)
  # Returns a raw Google geocoder search result (JSON).
  # This method is not intended for general use (prefer Geocoder.search).
  def self._fetch_raw_response(query)
    return nil if query.blank?

    # build URL
    params = { :address => query, :sensor  => "false" }
    url = "http://maps.google.com/maps/api/geocode/json?" + params.to_query
    # query geocoder and make sure it responds quickly
Alex Reisner's avatar
Alex Reisner committed
    begin
      resp = nil
      timeout(3) do
        Net::HTTP.get_response(URI.parse(url)).body
Alex Reisner's avatar
Alex Reisner committed
      end
    rescue SocketError, TimeoutError
      return nil
    end
  end
  ##
  # Name of the ActiveRecord scope method.
  #
  def self.scope_method_name
    begin
      Rails.version.starts_with?("3") ? :scope : :named_scope
    rescue NameError
      :named_scope
    end
Alex Reisner's avatar
Alex Reisner committed
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