diff --git a/lib/geocoder.rb b/lib/geocoder.rb index f7a96b3a27e694a6c6bb98697652b3d0fae3bd2e..bff786d0c0ce1be949d057a6db79fa3c87d1659c 100644 --- a/lib/geocoder.rb +++ b/lib/geocoder.rb @@ -1,208 +1,6 @@ require "geocoder/calculations" require "geocoder/lookup" - -## -# Add geocoding functionality to any object. -# -module Geocoder - - ## - # 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 (<tt>[lat,long]</tt>). - # - 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+ :: <tt>:mi</tt> (default) or <tt>:km</tt> - # +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: <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 - 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). - # - # <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt> - # - 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. - # - # <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt> - # - 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: <tt>[lat, lon]</tt>. - # - def fetch_coordinates(save = false) - coords = Geocoder::Lookup.coordinates( - send(self.class.geocoder_options[:address_attr]) - ) - 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 -end +require "geocoder/active_record" ## # Add geocoded_by method to ActiveRecord::Base so Geocoder is accessible. @@ -219,6 +17,6 @@ ActiveRecord::Base.class_eval do :latitude => options[:latitude] || :latitude, :longitude => options[:longitude] || :longitude } - include Geocoder + include Geocoder::ActiveRecord end end diff --git a/lib/geocoder/active_record.rb b/lib/geocoder/active_record.rb new file mode 100644 index 0000000000000000000000000000000000000000..0c9a2ce5a8f8a7017d044c62f24eeee33dc769af --- /dev/null +++ b/lib/geocoder/active_record.rb @@ -0,0 +1,204 @@ +## +# 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 (<tt>[lat,long]</tt>). + # + 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+ :: <tt>:mi</tt> (default) or <tt>:km</tt> + # +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: <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 + 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). + # + # <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt> + # + 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. + # + # <tt>:units</tt> :: <tt>:mi</tt> (default) or <tt>:km</tt> + # + 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: <tt>[lat, lon]</tt>. + # + def fetch_coordinates(save = false) + coords = Geocoder::Lookup.coordinates( + send(self.class.geocoder_options[:address_attr]) + ) + 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 + end +end