Skip to content
Snippets Groups Projects
geocoder.rb 4.96 KiB
Newer Older
  • Learn to ignore specific revisions
  • Alex Reisner's avatar
    Alex Reisner committed
    ##
    # Add geocoding functionality (via Google) to any object that implements
    
    # a method (+location+ by default) that returns a string suitable for a
    # Google Maps search. The object should also implement reader and writer
    # methods for +latitude+ and +longitude+ attributes.
    
    Alex Reisner's avatar
    Alex Reisner committed
    #
    module Geocoder
      
    
      ##
      # Implementation of 'included' hook method.
      #
      def self.included(base)
        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)
        data = self.search(query)
        
        # Make sure search found a result.
    
        unless data and data['kml']['response']['status']['code'] == "200"
          return nil
        end
        
        # Isolate the relevant part of the result.
    
    Alex Reisner's avatar
    Alex Reisner committed
        place = data['kml']['response']['placemark']
    
        # If there are multiple results, blindly use the first.
        place = place.first if place.is_a?(Array)
        coords = place['point']['coordinates']
        coords.split(',')[0...2].reverse.map{ |i| i.to_f }
      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(attribute = :location)
        Geocoder.fetch_coordinates(send(attribute))
    
    Alex Reisner's avatar
    Alex Reisner committed
      end
      
      ##
    
      # Fetch and assign +latitude+ and +longitude+.
    
    Alex Reisner's avatar
    Alex Reisner committed
      #
    
      def fetch_and_assign_coordinates(attribute = :location)
        if c = fetch_coordinates(attribute)
    
    Alex Reisner's avatar
    Alex Reisner committed
          self.latitude = c[0]
          self.longitude = c[1]
    
    Alex Reisner's avatar
    Alex Reisner committed
        end
      end
    
    
    Alex Reisner's avatar
    Alex Reisner committed
      # Calculate the distance between two points (Haversine formula). Takes two
      # sets of coordinates and an options hash:
    
    Alex Reisner's avatar
    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
        lat1 *= Math::PI / 180
        lon1 *= Math::PI / 180
        lat2 *= Math::PI / 180
        lon2 *= Math::PI / 180
        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
      ##
      # Find all 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
      #
      def self.nearby_mysql_query(table, latitude, longitude, radius = 20, options = {})
        
        # Alternate column names.
        options[:latitude]  ||= 'latitude'
        options[:longitude] ||= 'longitude'
        
        # 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);
        where  = "#{options[:latitude]} BETWEEN #{lat_lo} AND #{lat_hi} AND " +
          "#{options[:longitude]} BETWEEN #{lon_lo} AND #{lon_hi}"
    
        # Generate query.
        "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 " +
          "FROM #{table} WHERE #{where} HAVING distance <= #{radius}"
      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')
    
        Hash.from_xml(doc)
      end
    end