diff --git a/.document b/.document new file mode 100644 index 0000000000000000000000000000000000000000..ecf3673194b8b6963488dabc93d5f16fea93c5e9 --- /dev/null +++ b/.document @@ -0,0 +1,5 @@ +README.rdoc +lib/**/*.rb +bin/* +features/**/*.feature +LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..00c0b86e8a129e66ed0f5e6c85322b71e289d4cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.sw? +.DS_Store +coverage +rdoc +pkg diff --git a/MIT-LICENSE b/LICENSE similarity index 95% rename from MIT-LICENSE rename to LICENSE index 9376605b2b389013d30bd1dc0fd08aa3547255fb..c8b40ca8010a3455cb0c772f42d2dbffbce874d8 100644 --- a/MIT-LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2009 [name of plugin creator] +Copyright (c) 2009 Alex Reisner Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.rdoc b/README.rdoc index 6539add009de1cd73fd6d064faa1f2b2b39ae06e..db8eddb0b191cbe93f9776657e47718f1c28d12b 100644 --- a/README.rdoc +++ b/README.rdoc @@ -1,56 +1,72 @@ = Geocoder -Geocoder is a simple plugin for Rails that provides object geocoding (via -Google Maps) and some utilities for working with geocoded objects. The code can -be used as a standalone method provider or included in a class to give objects -geographic awareness. +Geocoder adds database-agnostic object geocoding to Rails (via Google). It does not rely on proprietary database functions so reasonably accurate distances can be calculated in MySQL or even SQLite. -== Setup +== Install -Use the Rails plugin install script: +Install either as a plugin: script/plugin install git://github.com/alexreisner/geocoder.git +or as a gem: + + # add to config/environment.rb: + config.gem "rails-geocoder", :lib => "geocoder", :source => "http://gemcutter.org/" + + # at command prompt: + sudo rake gems:install + +== Configure + To add geocoding features to a class: geocoded_by :location -Be sure your class defines read/write attributes +latitude+ and +longitude+ as -well as a method called +location+ (or whatever name you pass to +geocoded_by+) -that returns a string suitable for passing to a Google Maps search, for example: +Be sure your class defines attributes for storing latitude and longitude (use +float+ or +double+ database columns) and a location (human-readable address to be geocoded). These attribute names are all configurable; for example, to use +address+, +lat+, and +lon+ respectively: + + geocoded_by :address, :latitude => :lat, :longitude => :lon + +A geocodable string is anything you'd use to search Google Maps. Any of the following are acceptable: 714 Green St, Big Town, MO + Eiffel Tower, Paris, FR + Paris, TX, US -If your model has +address+, +city+, +state+, and +country+ attributes your -+location+ method might look something like this: +If your model has +address+, +city+, +state+, and +country+ attributes your +location+ method might look something like this: def location [address, city, state, country].compact.join(', ') end +== Use -== Examples +Assuming +Venue+ is a geocoded model: -Look up coordinates of an object: + Venue.find_near('Omaha, NE, US', 20) # venues within 20 miles of Omaha + Venue.find_near([40.71, 100.23], 20) # venues within 20 miles of a point + Venue.geocoded # venues with coordinates + Venue.not_geocoded # venues without coordinates - obj.fetch_coordinates # returns an array [lat, lon] - obj.fetch_and_assign_coordinates # writes values to +latitude+ and +longitude+ +Assuming +obj+ has a valid string for its +location+: -Find distance between object and a point: + obj.fetch_coordinates # returns coordinates [lat, lon] + obj.fetch_coordinates! # also writes coordinates to object - obj.distance_to(40.71432, -100.23487) # in miles - obj.distance_to(40.71432, -100.23487, :km) # in kilometers +Assuming +obj+ is geocoded (has latitude and longitude): -Find objects within 20 miles of a point: + obj.nearbys(30) # other objects within 30 miles + obj.distance_to(40.714, -100.234) # distance to arbitrary point - Venue.near('Omaha, NE, US', 20) # Venue is a geocoded model - -Please see the code for more methods and detailed information about arguments. +Some utility methods are also available: + # distance (in miles) between Eiffel Tower and Empire State Building + Geocoder.distance_between( 48.858205,2.294359, 40.748433,-73.985655 ) + + # look up coordinates of some location (like searching Google Maps) + Geocoder.fetch_coordinates("25 Main St, Cooperstown, NY") -== To-do -* +near+ method should also accept an array of coordinates as its first parameter +Please see the code for more methods and detailed information about arguments (eg, working with kilometers). Copyright (c) 2009 Alex Reisner, released under the MIT license diff --git a/Rakefile b/Rakefile index 82cdcbcf32532f2f54c7db00162fddae909e224d..2e22367180677c8e72b371b9647f72d1723ec816 100644 --- a/Rakefile +++ b/Rakefile @@ -1,22 +1,56 @@ +require 'rubygems' require 'rake' + +begin + require 'jeweler' + Jeweler::Tasks.new do |gem| + gem.name = "rails-geocoder" + gem.summary = %Q{Add geocoding functionality to Rails models.} + gem.description = %Q{Add geocoding functionality to Rails models.} + gem.email = "alex@alexreisner.com" + gem.homepage = "http://github.com/alexreisner/geocoder" + gem.authors = ["Alex Reisner"] + # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings + end + Jeweler::GemcutterTasks.new +rescue LoadError + puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler" +end + require 'rake/testtask' -require 'rake/rdoctask' +Rake::TestTask.new(:test) do |test| + test.libs << 'lib' << 'test' + test.pattern = 'test/**/*_test.rb' + test.verbose = true +end + +begin + require 'rcov/rcovtask' + Rcov::RcovTask.new do |test| + test.libs << 'test' + test.pattern = 'test/**/*_test.rb' + test.verbose = true + end +rescue LoadError + task :rcov do + abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov" + end +end + +task :test => :check_dependencies -desc 'Default: run unit tests.' task :default => :test -desc 'Test the geocoder plugin.' -Rake::TestTask.new(:test) do |t| - t.libs << 'lib' - t.pattern = 'test/**/*_test.rb' - t.verbose = true -end +require 'rake/rdoctask' +Rake::RDocTask.new do |rdoc| + if File.exist?('VERSION') + version = File.read('VERSION') + else + version = "" + end -desc 'Generate documentation for the geocoder plugin.' -Rake::RDocTask.new(:rdoc) do |rdoc| rdoc.rdoc_dir = 'rdoc' - rdoc.title = 'Geocoder' - rdoc.options << '--line-numbers' << '--inline-source' - rdoc.rdoc_files.include('README.rdoc') + rdoc.title = "geocoder #{version}" + rdoc.rdoc_files.include('README*') rdoc.rdoc_files.include('lib/**/*.rb') end diff --git a/VERSION b/VERSION new file mode 100644 index 0000000000000000000000000000000000000000..a3df0a6959e154733da89a5d6063742ce6d5b851 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.8.0 diff --git a/init.rb b/init.rb index ddd1d1723efb4b9407f4a666aa64cd71f896a35b..87883b6226c39d8b4701fdf8b3a9da9c0eade12a 100644 --- a/init.rb +++ b/init.rb @@ -1,11 +1 @@ -ActiveRecord::Base.class_eval do - - ## - # Include the Geocoder module and set the method name which returns - # the geo-search string. - # - def self.geocoded_by(method_name = :location) - include Geocoder - @geocoder_method_name = method_name - end -end +require 'geocoder' \ No newline at end of file diff --git a/lib/geocoder.rb b/lib/geocoder.rb index 793056d370749681df328d2f2760fc015fb2840a..3b08b42c40727be5ab67529e59281bf9edcdade3 100644 --- a/lib/geocoder.rb +++ b/lib/geocoder.rb @@ -12,33 +12,16 @@ module Geocoder # named scope: geocoded objects named_scope :geocoded, - :conditions => "latitude IS NOT NULL AND longitude IS NOT NULL" + :conditions => "#{geocoder_options[:latitude]} IS NOT NULL " + + "AND #{geocoder_options[:longitude]} IS NOT NULL" # named scope: not-geocoded objects named_scope :not_geocoded, - :conditions => "latitude IS NULL OR longitude IS NULL" + :conditions => "#{geocoder_options[:latitude]} IS NULL " + + "OR #{geocoder_options[:longitude]} IS NULL" end 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 - ## # Methods which will be class methods of the including class. # @@ -46,61 +29,145 @@ module Geocoder ## # Find all objects within a radius (in miles) of the given location - # (address string). + # (address string). Location (the first argument) may be either a string + # to geocode or an array of coordinates (<tt>[lat,long]</tt>). # - def near(location, radius = 100, options = {}) - latitude, longitude = Geocoder.fetch_coordinates(location) + def find_near(location, radius = 20, options = {}) + latitude, longitude = location.is_a?(Array) ? + location : Geocoder.fetch_coordinates(location) return [] unless (latitude and longitude) - query = nearby_mysql_query(latitude, longitude, radius.to_i, options) - find_by_sql(query) + all(find_near_options(latitude, longitude, radius, options)) end ## - # Generate a MySQL query to find all records within a radius (in miles) - # of a point. + # 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 nearby_mysql_query(latitude, longitude, radius = 20, options = {}) - table = options[:table_name] || self.to_s.tableize - options.delete :table_name # don't pass to nearby_mysql_query - Geocoder.nearby_mysql_query(table, latitude, longitude, radius, options) - end + def find_near_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 + ## - # Get the name of the method that returns the search string. + # Get the coordinates [lat,lon] of an object. This is not great but it + # seems cleaner than polluting the object method namespace. # - def geocoder_method_name - defined?(@geocoder_method_name) ? @geocoder_method_name : :location + def _get_coordinates(object) + [object.send(geocoder_options[:latitude]), + object.send(geocoder_options[:longitude])] end end + ## + # Is this object geocoded? (Does it have latitude and longitude?) + # + def geocoded? + self.class._get_coordinates(self).compact.size > 0 + end + ## # 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) + return nil unless geocoded? + mylat,mylon = self.class._get_coordinates(self) + Geocoder.distance_between(mylat, mylon, lat, lon, :units => units) end ## - # Fetch coordinates based on the object's object's +location+. Returns an - # array <tt>[lat,lon]</tt>. + # Get other geocoded objects within a given radius. + # The object must be geocoded before this method is called. + # + def nearbys(radius = 20) + return [] unless geocoded? + lat,lon = self.class._get_coordinates(self) + self.class.find_near([lat, lon], radius) - [self] + end + + ## + # Fetch coordinates based on the object's location. + # Returns an array <tt>[lat,lon]</tt>. # def fetch_coordinates - Geocoder.fetch_coordinates(send(self.class.geocoder_method_name)) + location = read_attribute(self.class.geocoder_options[:method_name]) + Geocoder.fetch_coordinates(location) end ## - # Fetch and assign +latitude+ and +longitude+. + # Fetch coordinates and assign +latitude+ and +longitude+. # - def fetch_and_assign_coordinates + def fetch_coordinates! returning fetch_coordinates do |c| unless c.blank? - self.latitude = c[0] - self.longitude = c[1] + write_attribute(self.class.geocoder_options[:latitude], c[0]) + write_attribute(self.class.geocoder_options[:longitude], c[1]) end end 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: @@ -108,19 +175,23 @@ module Geocoder # +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 + + # define conversion factors 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 + 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)) @@ -128,52 +199,12 @@ module Geocoder end ## - # 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 - # - # 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) + # Convert degrees to radians. # - def self.nearby_mysql_query(table, latitude, longitude, radius = 20, options = {}) - - # Alternate column names. - options[:latitude] ||= 'latitude' - options[:longitude] ||= 'longitude' - options[:order] ||= 'distance ASC' - - # 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}" - - # Build limit clause. - limit = "" - if options[:limit] or options[:offset] - options[:offset] ||= 0 - limit = "LIMIT #{options[:offset]},#{options[:limit]}" - end - - # 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} " + - "ORDER BY #{options[:order]} #{limit}" + def self.to_radians(degrees) + degrees * (Math::PI / 180) end - + ## # Query Google for geographic information about the given phrase. # Returns the XML response as a hash. This method is not intended for @@ -202,3 +233,22 @@ module Geocoder REXML::Document.new(doc) 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 diff --git a/rails-geocoder.gemspec b/rails-geocoder.gemspec new file mode 100644 index 0000000000000000000000000000000000000000..a07d487110cbb1107cd2912471716e3e27a279bc --- /dev/null +++ b/rails-geocoder.gemspec @@ -0,0 +1,51 @@ +# Generated by jeweler +# DO NOT EDIT THIS FILE +# Instead, edit Jeweler::Tasks in Rakefile, and run `rake gemspec` +# -*- encoding: utf-8 -*- + +Gem::Specification.new do |s| + s.name = %q{rails-geocoder} + s.version = "0.8.0" + + s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= + s.authors = ["Alex Reisner"] + s.date = %q{2009-10-01} + s.description = %q{Add geocoding functionality to Rails models.} + s.email = %q{alex@alexreisner.com} + s.extra_rdoc_files = [ + "LICENSE", + "README.rdoc" + ] + s.files = [ + ".document", + ".gitignore", + "LICENSE", + "README.rdoc", + "Rakefile", + "VERSION", + "init.rb", + "lib/geocoder.rb", + "rails-geocoder.gemspec", + "test/geocoder_test.rb", + "test/test_helper.rb" + ] + s.homepage = %q{http://github.com/alexreisner/geocoder} + s.rdoc_options = ["--charset=UTF-8"] + s.require_paths = ["lib"] + s.rubygems_version = %q{1.3.5} + s.summary = %q{Add geocoding functionality to Rails models.} + s.test_files = [ + "test/geocoder_test.rb", + "test/test_helper.rb" + ] + + if s.respond_to? :specification_version then + current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION + s.specification_version = 3 + + if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then + else + end + else + end +end diff --git a/tasks/geocoder_tasks.rake b/tasks/geocoder_tasks.rake deleted file mode 100644 index bca61327b96917742930028f88f2e104c4344e01..0000000000000000000000000000000000000000 --- a/tasks/geocoder_tasks.rake +++ /dev/null @@ -1,4 +0,0 @@ -# desc "Explaining what the task does" -# task :geocoder do -# # Task goes here -# end diff --git a/test/geocoder_test.rb b/test/geocoder_test.rb index b9e1039fb9f04289895d8f98b054a71ff4c95606..2dee9ea20653a4337ed7176164819432ef8fa3f0 100644 --- a/test/geocoder_test.rb +++ b/test/geocoder_test.rb @@ -1,4 +1,4 @@ -require 'test/unit' +require 'test_helper' class GeocoderTest < Test::Unit::TestCase # Replace this with your real tests. diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..0d94d705b9702c8fedc537bb91c877cbf645d381 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,9 @@ +require 'rubygems' +require 'test/unit' + +$LOAD_PATH.unshift(File.dirname(__FILE__)) +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) +require 'geocoder' + +class Test::Unit::TestCase +end