## # 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