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