mappable.rb 16.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432
require 'geo_kit/defaults'

module GeoKit
  # Contains class and instance methods providing distance calcuation services.  This
  # module is meant to be mixed into classes containing lat and lng attributes where
  # distance calculation is desired.  
  # 
  # At present, two forms of distance calculations are provided:
  # 
  # * Pythagorean Theory (flat Earth) - which assumes the world is flat and loses accuracy over long distances.
  # * Haversine (sphere) - which is fairly accurate, but at a performance cost.
  # 
  # Distance units supported are :miles and :kms.
  module Mappable
    PI_DIV_RAD = 0.0174
    KMS_PER_MILE = 1.609
    EARTH_RADIUS_IN_MILES = 3963.19
    EARTH_RADIUS_IN_KMS = EARTH_RADIUS_IN_MILES * KMS_PER_MILE
    MILES_PER_LATITUDE_DEGREE = 69.1
    KMS_PER_LATITUDE_DEGREE = MILES_PER_LATITUDE_DEGREE * KMS_PER_MILE
    LATITUDE_DEGREES = EARTH_RADIUS_IN_MILES / MILES_PER_LATITUDE_DEGREE  
    
    # Mix below class methods into the includer.
    def self.included(receiver) # :nodoc:
      receiver.extend ClassMethods
    end   
    
    module ClassMethods #:nodoc:
      # Returns the distance between two points.  The from and to parameters are
      # required to have lat and lng attributes.  Valid options are:
      # :units - valid values are :miles or :kms (GeoKit::default_units is the default)
      # :formula - valid values are :flat or :sphere (GeoKit::default_formula is the default)
      def distance_between(from, to, options={})
        from=GeoKit::LatLng.normalize(from)
        to=GeoKit::LatLng.normalize(to)
        return 0.0 if from == to # fixes a "zero-distance" bug
        units = options[:units] || GeoKit::default_units
        formula = options[:formula] || GeoKit::default_formula
        case formula
        when :sphere          
          units_sphere_multiplier(units) * 
              Math.acos( Math.sin(deg2rad(from.lat)) * Math.sin(deg2rad(to.lat)) + 
              Math.cos(deg2rad(from.lat)) * Math.cos(deg2rad(to.lat)) * 
              Math.cos(deg2rad(to.lng) - deg2rad(from.lng)))   
        when :flat
          Math.sqrt((units_per_latitude_degree(units)*(from.lat-to.lat))**2 + 
              (units_per_longitude_degree(from.lat, units)*(from.lng-to.lng))**2)
        end
      end

      # Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
      # from the first point to the second point. Typicaly, the instance methods will be used 
      # instead of this method.
      def heading_between(from,to)
        from=GeoKit::LatLng.normalize(from)
        to=GeoKit::LatLng.normalize(to)

        d_lng=deg2rad(to.lng-from.lng)
        from_lat=deg2rad(from.lat)
        to_lat=deg2rad(to.lat) 
        y=Math.sin(d_lng) * Math.cos(to_lat)
        x=Math.cos(from_lat)*Math.sin(to_lat)-Math.sin(from_lat)*Math.cos(to_lat)*Math.cos(d_lng)
        heading=to_heading(Math.atan2(y,x))
      end
  
      # Given a start point, distance, and heading (in degrees), provides
      # an endpoint. Returns a LatLng instance. Typically, the instance method
      # will be used instead of this method.
      def endpoint(start,heading, distance, options={})
        units = options[:units] || GeoKit::default_units
        radius = units == :miles ? EARTH_RADIUS_IN_MILES : EARTH_RADIUS_IN_KMS
        start=GeoKit::LatLng.normalize(start)        
        lat=deg2rad(start.lat)
        lng=deg2rad(start.lng)
        heading=deg2rad(heading)
        distance=distance.to_f
        
        end_lat=Math.asin(Math.sin(lat)*Math.cos(distance/radius) +
                          Math.cos(lat)*Math.sin(distance/radius)*Math.cos(heading))

        end_lng=lng+Math.atan2(Math.sin(heading)*Math.sin(distance/radius)*Math.cos(lat),
                               Math.cos(distance/radius)-Math.sin(lat)*Math.sin(end_lat))

        LatLng.new(rad2deg(end_lat),rad2deg(end_lng))
      end

      # Returns the midpoint, given two points. Returns a LatLng. 
      # Typically, the instance method will be used instead of this method.
      # Valid option:
      #   :units - valid values are :miles or :kms (:miles is the default)
      def midpoint_between(from,to,options={})
        from=GeoKit::LatLng.normalize(from)

        units = options[:units] || GeoKit::default_units
        
        heading=from.heading_to(to)
        distance=from.distance_to(to,options)
        midpoint=from.endpoint(heading,distance/2,options)
      end
  
      # Geocodes a location using the multi geocoder.
      def geocode(location)
        res = Geocoders::MultiGeocoder.geocode(location)
        return res if res.success
        raise GeoKit::Geocoders::GeocodeError      
      end
    
      protected
    
      def deg2rad(degrees)
        degrees.to_f / 180.0 * Math::PI
      end
      
      def rad2deg(rad)
        rad.to_f * 180.0 / Math::PI 
      end
      
      def to_heading(rad)
        (rad2deg(rad)+360)%360
      end

      # Returns the multiplier used to obtain the correct distance units.
      def units_sphere_multiplier(units)
        units == :miles ? EARTH_RADIUS_IN_MILES : EARTH_RADIUS_IN_KMS
      end

      # Returns the number of units per latitude degree.
      def units_per_latitude_degree(units)
        units == :miles ? MILES_PER_LATITUDE_DEGREE : KMS_PER_LATITUDE_DEGREE
      end
    
      # Returns the number units per longitude degree.
      def units_per_longitude_degree(lat, units)
        miles_per_longitude_degree = (LATITUDE_DEGREES * Math.cos(lat * PI_DIV_RAD)).abs
        units == :miles ? miles_per_longitude_degree : miles_per_longitude_degree * KMS_PER_MILE
      end  
    end
  
    # -----------------------------------------------------------------------------------------------
    # Instance methods below here
    # -----------------------------------------------------------------------------------------------
  
    # Extracts a LatLng instance. Use with models that are acts_as_mappable
    def to_lat_lng
      return self if instance_of?(GeoKit::LatLng) || instance_of?(GeoKit::GeoLoc)
      return LatLng.new(send(self.class.lat_column_name),send(self.class.lng_column_name)) if self.class.respond_to?(:acts_as_mappable)
      return nil
    end

    # Returns the distance from another point.  The other point parameter is
    # required to have lat and lng attributes.  Valid options are:
    # :units - valid values are :miles or :kms (:miles is the default)
    # :formula - valid values are :flat or :sphere (:sphere is the default)
    def distance_to(other, options={})
      self.class.distance_between(self, other, options)
    end  
    alias distance_from distance_to

    # Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
    # to the given point. The given point can be a LatLng or a string to be Geocoded 
    def heading_to(other)
      self.class.heading_between(self,other)
    end

    # Returns heading in degrees (0 is north, 90 is east, 180 is south, etc)
    # FROM the given point. The given point can be a LatLng or a string to be Geocoded 
    def heading_from(other)
      self.class.heading_between(other,self)
    end
 
    # Returns the endpoint, given a heading (in degrees) and distance.  
    # Valid option:
    # :units - valid values are :miles or :kms (:miles is the default)
    def endpoint(heading,distance,options={})
      self.class.endpoint(self,heading,distance,options)  
    end

    # Returns the midpoint, given another point on the map.  
    # Valid option:
    # :units - valid values are :miles or :kms (:miles is the default)    
    def midpoint_to(other, options={})
      self.class.midpoint_between(self,other,options)
    end
    
  end

  class LatLng 
    include Mappable

    attr_accessor :lat, :lng

    # Accepts latitude and longitude or instantiates an empty instance
    # if lat and lng are not provided. Converted to floats if provided
    def initialize(lat=nil, lng=nil)
      lat = lat.to_f if lat && !lat.is_a?(Numeric)
      lng = lng.to_f if lng && !lng.is_a?(Numeric)
      @lat = lat
      @lng = lng
    end 

    # Latitude attribute setter; stored as a float.
    def lat=(lat)
      @lat = lat.to_f if lat
    end

    # Longitude attribute setter; stored as a float;
    def lng=(lng)
      @lng=lng.to_f if lng
    end  

    # Returns the lat and lng attributes as a comma-separated string.
    def ll
      "#{lat},#{lng}"
    end
    
    #returns a string with comma-separated lat,lng values
    def to_s
      ll
    end
  
    #returns a two-element array
    def to_a
      [lat,lng]
    end
    # Returns true if the candidate object is logically equal.  Logical equivalence
    # is true if the lat and lng attributes are the same for both objects.
    def ==(other)
      other.is_a?(LatLng) ? self.lat == other.lat && self.lng == other.lng : false
    end
    
    # A *class* method to take anything which can be inferred as a point and generate
    # a LatLng from it. You should use this anything you're not sure what the input is,
    # and want to deal with it as a LatLng if at all possible. Can take:
    #  1) two arguments (lat,lng)
    #  2) a string in the format "37.1234,-129.1234" or "37.1234 -129.1234"
    #  3) a string which can be geocoded on the fly
    #  4) an array in the format [37.1234,-129.1234]
    #  5) a LatLng or GeoLoc (which is just passed through as-is)
    #  6) anything which acts_as_mappable -- a LatLng will be extracted from it
    def self.normalize(thing,other=nil)
      # if an 'other' thing is supplied, normalize the input by creating an array of two elements
      thing=[thing,other] if other
      
      if thing.is_a?(String)
        thing.strip!
        if match=thing.match(/(\-?\d+\.?\d*)[, ] ?(\-?\d+\.?\d*)$/)
          return GeoKit::LatLng.new(match[1],match[2])
        else
          res = GeoKit::Geocoders::MultiGeocoder.geocode(thing)
          return res if res.success
          raise GeoKit::Geocoders::GeocodeError  
        end
      elsif thing.is_a?(Array) && thing.size==2
        return GeoKit::LatLng.new(thing[0],thing[1])
      elsif thing.is_a?(LatLng) # will also be true for GeoLocs
        return thing
      elsif thing.class.respond_to?(:acts_as_mappable) && thing.class.respond_to?(:distance_column_name)
        return thing.to_lat_lng
      end
      
      throw ArgumentError.new("#{thing} (#{thing.class}) cannot be normalized to a LatLng. We tried interpreting it as an array, string, Mappable, etc., but no dice.")
    end
    
  end

  # This class encapsulates the result of a geocoding call
  # It's primary purpose is to homogenize the results of multiple
  # geocoding providers. It also provides some additional functionality, such as 
  # the "full address" method for geocoders that do not provide a 
  # full address in their results (for example, Yahoo), and the "is_us" method.
  class GeoLoc < LatLng
    # Location attributes.  Full address is a concatenation of all values.  For example:
    # 100 Spear St, San Francisco, CA, 94101, US
    attr_accessor :street_address, :city, :state, :zip, :country_code, :full_address
    # Attributes set upon return from geocoding.  Success will be true for successful
    # geocode lookups.  The provider will be set to the name of the providing geocoder.
    # Finally, precision is an indicator of the accuracy of the geocoding.
    attr_accessor :success, :provider, :precision
    # Street number and street name are extracted from the street address attribute.
    attr_reader :street_number, :street_name

    # Constructor expects a hash of symbols to correspond with attributes.
    def initialize(h={})
      @street_address=h[:street_address] 
      @city=h[:city] 
      @state=h[:state] 
      @zip=h[:zip] 
      @country_code=h[:country_code] 
      @success=false
      @precision='unknown'
      super(h[:lat],h[:lng])
    end

    # Returns true if geocoded to the United States.
    def is_us?
      country_code == 'US'
    end

    # full_address is provided by google but not by yahoo. It is intended that the google
    # geocoding method will provide the full address, whereas for yahoo it will be derived
    # from the parts of the address we do have.
    def full_address
      @full_address ? @full_address : to_geocodeable_s
    end

    # Extracts the street number from the street address if the street address
    # has a value.
    def street_number
      street_address[/(\d*)/] if street_address
    end

    # Returns the street name portion of the street address.
    def street_name
       street_address[street_number.length, street_address.length].strip if street_address
    end

    # gives you all the important fields as key-value pairs
    def hash
      res={}
      [:success,:lat,:lng,:country_code,:city,:state,:zip,:street_address,:provider,:full_address,:is_us?,:ll,:precision].each { |s| res[s] = self.send(s.to_s) }
      res
    end
    alias to_hash hash

    # Sets the city after capitalizing each word within the city name.
    def city=(city)
      @city = city.titleize if city
    end

    # Sets the street address after capitalizing each word within the street address.
    def street_address=(address)
      @street_address = address.titleize if address
    end  

    # Returns a comma-delimited string consisting of the street address, city, state,
    # zip, and country code.  Only includes those attributes that are non-blank.
    def to_geocodeable_s
      a=[street_address, city, state, zip, country_code].compact
      a.delete_if { |e| !e || e == '' }
      a.join(', ')      
    end

    # Returns a string representation of the instance.
    def to_s
      "Provider: #{provider}\n Street: #{street_address}\nCity: #{city}\nState: #{state}\nZip: #{zip}\nLatitude: #{lat}\nLongitude: #{lng}\nCountry: #{country_code}\nSuccess: #{success}"
    end
  end
  
  # Bounds represents a rectangular bounds, defined by the SW and NE corners
  class Bounds
    # sw and ne are LatLng objects
    attr_accessor :sw, :ne
    
    # provide sw and ne to instantiate a new Bounds instance
    def initialize(sw,ne)
      raise ArguementError if !(sw.is_a?(GeoKit::LatLng) && ne.is_a?(GeoKit::LatLng))
      @sw,@ne=sw,ne
    end
    
    #returns the a single point which is the center of the rectangular bounds
    def center
      @sw.midpoint_to(@ne)
    end
  
    # a simple string representation:sw,ne
    def to_s
      "#{@sw.to_s},#{@ne.to_s}"   
    end
    
    # a two-element array of two-element arrays: sw,ne
    def to_a
      [@sw.to_a, @ne.to_a]
    end
    
    # Returns true if the bounds contain the passed point.
    # allows for bounds which cross the meridian
    def contains?(point)
      point=GeoKit::LatLng.normalize(point)
      res = point.lat > @sw.lat && point.lat < @ne.lat
      if crosses_meridian?
        res &= point.lng < @ne.lng || point.lng > @sw.lng
      else
        res &= point.lng < @ne.lng && point.lng > @sw.lng
      end
      res
    end
    
    # returns true if the bounds crosses the international dateline
    def crosses_meridian?
      @sw.lng > @ne.lng 
    end

    # Returns true if the candidate object is logically equal.  Logical equivalence
    # is true if the lat and lng attributes are the same for both objects.
    def ==(other)
      other.is_a?(Bounds) ? self.sw == other.sw && self.ne == other.ne : false
    end
    
    class <<self
      
      # returns an instance of bounds which completely encompases the given circle
      def from_point_and_radius(point,radius,options={})
        point=LatLng.normalize(point)
        p0=point.endpoint(0,radius,options)
        p90=point.endpoint(90,radius,options)
        p180=point.endpoint(180,radius,options)
        p270=point.endpoint(270,radius,options)
        sw=GeoKit::LatLng.new(p180.lat,p270.lng)
        ne=GeoKit::LatLng.new(p0.lat,p90.lng)
        GeoKit::Bounds.new(sw,ne)
      end
      
      # Takes two main combinations of arguements to create a bounds:
      # point,point   (this is the only one which takes two arguments
      # [point,point]
      # . . . where a point is anything LatLng#normalize can handle (which is quite a lot)
      #
      # NOTE: everything combination is assumed to pass points in the order sw, ne
      def normalize (thing,other=nil)   
        # maybe this will be simple -- an actual bounds object is passed, and we can all go home
        return thing if thing.is_a? Bounds
        
        # no? OK, if there's no "other," the thing better be a two-element array        
        thing,other=thing if !other && thing.is_a?(Array) && thing.size==2

        # Now that we're set with a thing and another thing, let LatLng do the heavy lifting.
        # Exceptions may be thrown
        Bounds.new(GeoKit::LatLng.normalize(thing),GeoKit::LatLng.normalize(other))
      end
    end
  end
end