Class: Philiprehberger::GeoPoint::Point
- Inherits:
-
Object
- Object
- Philiprehberger::GeoPoint::Point
- Defined in:
- lib/philiprehberger/geo_point/point.rb
Overview
Represents a geographic coordinate with latitude and longitude. Provides Haversine/Vincenty distance, bearing, midpoint, destination, geohash, cross-track distance, polygon containment, and rhumb line calculations.
Constant Summary collapse
- EARTH_RADIUS_KM =
6371.0- UNIT_MULTIPLIERS =
{ km: 1.0, mi: 0.621371, m: 1000.0, nm: 0.539957 }.freeze
- WGS84_A =
WGS84 ellipsoid parameters
6_378_137.0- WGS84_F =
1.0 / 298.257223563
- WGS84_B =
WGS84_A * (1 - WGS84_F)
- GEOHASH_BASE32 =
'0123456789bcdefghjkmnpqrstuvwxyz'
Instance Attribute Summary collapse
-
#lat ⇒ Object
readonly
Returns the value of attribute lat.
-
#lon ⇒ Object
readonly
Returns the value of attribute lon.
Class Method Summary collapse
-
.from_dms(lat, lon) ⇒ Point
Parse degrees-minutes-seconds strings into a Point.
Instance Method Summary collapse
- #==(other) ⇒ Object
- #bearing_to(other) ⇒ Object
- #cross_track_distance(path_start, path_end, unit: :km) ⇒ Object
- #destination(*args, distance: nil, bearing: nil, unit: :km) ⇒ Object
- #distance_to(other, unit: :km, method: :haversine) ⇒ Object
- #eql?(other) ⇒ Boolean
-
#equirectangular_distance_to(other, unit: :km) ⇒ Float
Fast approximate distance using equirectangular projection.
- #hash ⇒ Object
-
#initialize(lat, lon) ⇒ Point
constructor
A new instance of Point.
- #inspect ⇒ Object
-
#interpolate(other, fraction) ⇒ Point
Compute the Point at the given fraction along the great-circle path between ‘self` and `other` using spherical linear interpolation (slerp).
- #midpoint(other) ⇒ Object
- #rhumb_bearing_to(other) ⇒ Object
- #rhumb_distance_to(other, unit: :km) ⇒ Object
- #to_a ⇒ Object
- #to_dms ⇒ Object
- #to_geohash(precision: 12) ⇒ Object
- #to_h ⇒ Object
- #to_s ⇒ Object
Constructor Details
#initialize(lat, lon) ⇒ Point
Returns a new instance of Point.
26 27 28 29 30 31 32 33 34 35 |
# File 'lib/philiprehberger/geo_point/point.rb', line 26 def initialize(lat, lon) lat = Float(lat) lon = Float(lon) raise ArgumentError, "Latitude must be between -90 and 90, got #{lat}" unless lat.between?(-90, 90) raise ArgumentError, "Longitude must be between -180 and 180, got #{lon}" unless lon.between?(-180, 180) @lat = lat @lon = lon end |
Instance Attribute Details
#lat ⇒ Object (readonly)
Returns the value of attribute lat.
24 25 26 |
# File 'lib/philiprehberger/geo_point/point.rb', line 24 def lat @lat end |
#lon ⇒ Object (readonly)
Returns the value of attribute lon.
24 25 26 |
# File 'lib/philiprehberger/geo_point/point.rb', line 24 def lon @lon end |
Class Method Details
.from_dms(lat, lon) ⇒ Point
Parse degrees-minutes-seconds strings into a Point.
Accepted forms (case-insensitive):
- `"40°45'30\"N"`, `"40 45 30 N"` (with hemisphere suffix N/S/E/W)
- `"40°45'30.5\"N"` (decimal seconds)
- `"40.7583"` / `"-40.7583"` (plain decimal-degree, returned as-is)
283 284 285 |
# File 'lib/philiprehberger/geo_point/point.rb', line 283 def self.from_dms(lat, lon) new(parse_dms(lat, hemisphere: %w[N S]), parse_dms(lon, hemisphere: %w[E W])) end |
Instance Method Details
#==(other) ⇒ Object
321 322 323 |
# File 'lib/philiprehberger/geo_point/point.rb', line 321 def ==(other) other.is_a?(self.class) && @lat == other.lat && @lon == other.lon end |
#bearing_to(other) ⇒ Object
79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/philiprehberger/geo_point/point.rb', line 79 def bearing_to(other) lat1 = deg_to_rad(@lat) lat2 = deg_to_rad(other.lat) dlon = deg_to_rad(other.lon - @lon) y = Math.sin(dlon) * Math.cos(lat2) x = (Math.cos(lat1) * Math.sin(lat2)) - (Math.sin(lat1) * Math.cos(lat2) * Math.cos(dlon)) (rad_to_deg(Math.atan2(y, x)) + 360) % 360 end |
#cross_track_distance(path_start, path_end, unit: :km) ⇒ Object
219 220 221 222 223 224 225 226 227 228 229 230 231 |
# File 'lib/philiprehberger/geo_point/point.rb', line 219 def cross_track_distance(path_start, path_end, unit: :km) validate_unit!(unit) d_start_to_self = path_start.distance_to(self, unit: :km) / EARTH_RADIUS_KM bearing_start_to_self = deg_to_rad(path_start.bearing_to(self)) bearing_start_to_end = deg_to_rad(path_start.bearing_to(path_end)) cross_track_rad = Math.asin( Math.sin(d_start_to_self) * Math.sin(bearing_start_to_self - bearing_start_to_end) ) km_to_unit(cross_track_rad * EARTH_RADIUS_KM, unit) end |
#destination(*args, distance: nil, bearing: nil, unit: :km) ⇒ Object
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 |
# File 'lib/philiprehberger/geo_point/point.rb', line 148 def destination(*args, distance: nil, bearing: nil, unit: :km) # Keyword-only form: destination(distance: meters, bearing: degrees). # When both kwargs are provided (and no positional args), distance is in meters. if args.empty? && !distance.nil? && !bearing.nil? return destination_meters_kw(distance, bearing) end raise ArgumentError, 'wrong number of arguments (given 0, expected 2)' if args.length < 2 bearing_arg, distance_arg = args validate_unit!(unit) d = unit_to_km(distance_arg, unit) / EARTH_RADIUS_KM brng = deg_to_rad(bearing_arg) lat1 = deg_to_rad(@lat) lon1 = deg_to_rad(@lon) lat2 = Math.asin( (Math.sin(lat1) * Math.cos(d)) + (Math.cos(lat1) * Math.sin(d) * Math.cos(brng)) ) lon2 = lon1 + Math.atan2( Math.sin(brng) * Math.sin(d) * Math.cos(lat1), Math.cos(d) - (Math.sin(lat1) * Math.sin(lat2)) ) self.class.new(rad_to_deg(lat2), normalize_lon(rad_to_deg(lon2))) end |
#distance_to(other, unit: :km, method: :haversine) ⇒ Object
37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/philiprehberger/geo_point/point.rb', line 37 def distance_to(other, unit: :km, method: :haversine) validate_unit!(unit) case method when :haversine haversine_distance(other, unit) when :vincenty vincenty_distance(other, unit) else raise ArgumentError, "Unknown method :#{method}. Valid methods: haversine, vincenty" end end |
#eql?(other) ⇒ Boolean
325 326 327 |
# File 'lib/philiprehberger/geo_point/point.rb', line 325 def eql?(other) self == other end |
#equirectangular_distance_to(other, unit: :km) ⇒ Float
Fast approximate distance using equirectangular projection.
Treats latitude/longitude as a flat Cartesian plane scaled by ‘cos(mean_lat)`. Significantly faster than Haversine (no trig on the hot path) and accurate to within ~0.5% for distances under 100 km. Use Haversine for longer distances or where exact values matter; this is intended for proximity sorting / clustering on dense datasets.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/philiprehberger/geo_point/point.rb', line 63 def equirectangular_distance_to(other, unit: :km) raise ArgumentError, 'other must be a Point' unless other.is_a?(self.class) validate_unit!(unit) lat1 = deg_to_rad(@lat) lat2 = deg_to_rad(other.lat) dlat = lat2 - lat1 dlon = deg_to_rad(other.lon - @lon) x = dlon * Math.cos((lat1 + lat2) / 2.0) km = Math.sqrt((x**2) + (dlat**2)) * EARTH_RADIUS_KM km_to_unit(km, unit) end |
#hash ⇒ Object
329 330 331 |
# File 'lib/philiprehberger/geo_point/point.rb', line 329 def hash [@lat, @lon].hash end |
#inspect ⇒ Object
337 338 339 |
# File 'lib/philiprehberger/geo_point/point.rb', line 337 def inspect "#<#{self.class} lat=#{@lat} lon=#{@lon}>" end |
#interpolate(other, fraction) ⇒ Point
Compute the Point at the given fraction along the great-circle path between ‘self` and `other` using spherical linear interpolation (slerp).
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 |
# File 'lib/philiprehberger/geo_point/point.rb', line 117 def interpolate(other, fraction) raise ArgumentError, 'fraction must be Numeric' unless fraction.is_a?(Numeric) f = fraction.to_f lat1 = deg_to_rad(@lat) lat2 = deg_to_rad(other.lat) lon1 = deg_to_rad(@lon) lon2 = deg_to_rad(other.lon) cos_delta = (Math.sin(lat1) * Math.sin(lat2)) + (Math.cos(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1)) cos_delta = 1.0 if cos_delta > 1.0 cos_delta = -1.0 if cos_delta < -1.0 delta = Math.acos(cos_delta) sin_delta = Math.sin(delta) return self.class.new(@lat, @lon) if sin_delta.abs < 1e-12 a = Math.sin((1 - f) * delta) / sin_delta b = Math.sin(f * delta) / sin_delta x = (a * Math.cos(lat1) * Math.cos(lon1)) + (b * Math.cos(lat2) * Math.cos(lon2)) y = (a * Math.cos(lat1) * Math.sin(lon1)) + (b * Math.cos(lat2) * Math.sin(lon2)) z = (a * Math.sin(lat1)) + (b * Math.sin(lat2)) lat_i = Math.atan2(z, Math.sqrt((x**2) + (y**2))) lon_i = Math.atan2(y, x) self.class.new(rad_to_deg(lat_i), normalize_lon(rad_to_deg(lon_i))) end |
#midpoint(other) ⇒ Object
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/philiprehberger/geo_point/point.rb', line 91 def midpoint(other) lat1 = deg_to_rad(@lat) lat2 = deg_to_rad(other.lat) lon1 = deg_to_rad(@lon) dlon = deg_to_rad(other.lon - @lon) bx = Math.cos(lat2) * Math.cos(dlon) by = Math.cos(lat2) * Math.sin(dlon) mid_lat = Math.atan2( Math.sin(lat1) + Math.sin(lat2), Math.sqrt(((Math.cos(lat1) + bx)**2) + (by**2)) ) mid_lon = lon1 + Math.atan2(by, Math.cos(lat1) + bx) self.class.new(rad_to_deg(mid_lat), rad_to_deg(mid_lon)) end |
#rhumb_bearing_to(other) ⇒ Object
256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/philiprehberger/geo_point/point.rb', line 256 def rhumb_bearing_to(other) lat1 = deg_to_rad(@lat) lat2 = deg_to_rad(other.lat) dlon = deg_to_rad(other.lon - @lon) d_psi = Math.log(Math.tan((Math::PI / 4) + (lat2 / 2.0)) / Math.tan((Math::PI / 4) + (lat1 / 2.0))) dlon -= (2 * Math::PI) * (dlon <=> 0) if dlon.abs > Math::PI (rad_to_deg(Math.atan2(dlon, d_psi)) + 360) % 360 end |
#rhumb_distance_to(other, unit: :km) ⇒ Object
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/philiprehberger/geo_point/point.rb', line 233 def rhumb_distance_to(other, unit: :km) validate_unit!(unit) lat1 = deg_to_rad(@lat) lat2 = deg_to_rad(other.lat) dlat = lat2 - lat1 dlon = deg_to_rad(other.lon - @lon) d_psi = Math.log(Math.tan((Math::PI / 4) + (lat2 / 2.0)) / Math.tan((Math::PI / 4) + (lat1 / 2.0))) q = if d_psi.abs > 1e-12 dlat / d_psi else Math.cos(lat1) end dlon -= (2 * Math::PI) * (dlon <=> 0) if dlon.abs > Math::PI dist = Math.sqrt((dlat**2) + ((q**2) * (dlon**2))) * EARTH_RADIUS_KM km_to_unit(dist, unit) end |
#to_a ⇒ Object
313 314 315 |
# File 'lib/philiprehberger/geo_point/point.rb', line 313 def to_a [@lat, @lon] end |
#to_dms ⇒ Object
268 269 270 |
# File 'lib/philiprehberger/geo_point/point.rb', line 268 def to_dms "#{format_dms(@lat, 'N', 'S')} #{format_dms(@lon, 'E', 'W')}" end |
#to_geohash(precision: 12) ⇒ Object
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 |
# File 'lib/philiprehberger/geo_point/point.rb', line 177 def to_geohash(precision: 12) raise ArgumentError, "Precision must be between 1 and 12, got #{precision}" unless precision.between?(1, 12) lat_range = [-90.0, 90.0] lon_range = [-180.0, 180.0] is_lon = true bit = 0 ch = 0 hash = +'' (precision * 5).times do if is_lon mid = (lon_range[0] + lon_range[1]) / 2.0 if @lon >= mid ch |= (1 << (4 - bit)) lon_range[0] = mid else lon_range[1] = mid end else mid = (lat_range[0] + lat_range[1]) / 2.0 if @lat >= mid ch |= (1 << (4 - bit)) lat_range[0] = mid else lat_range[1] = mid end end is_lon = !is_lon bit += 1 next unless bit == 5 hash << GEOHASH_BASE32[ch] bit = 0 ch = 0 end hash end |
#to_h ⇒ Object
317 318 319 |
# File 'lib/philiprehberger/geo_point/point.rb', line 317 def to_h { lat: @lat, lon: @lon } end |
#to_s ⇒ Object
333 334 335 |
# File 'lib/philiprehberger/geo_point/point.rb', line 333 def to_s "(#{@lat}, #{@lon})" end |